320 lines
12 KiB
C#
320 lines
12 KiB
C#
// Program.cs — ASP.NET Core 8 backend: builds FAT12 on-the-fly (GET) and extracts on POST
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using System.Linq;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Require client certificates (mTLS). Configure server cert via appsettings.json or env variables.
|
|
builder.WebHost.ConfigureKestrel(k =>
|
|
{
|
|
k.ConfigureHttpsDefaults(o =>
|
|
{
|
|
o.ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode.RequireCertificate;
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
// Basic client-cert gate (replace with your CA/allow-list validation)
|
|
app.Use(async (ctx, next) =>
|
|
{
|
|
if (ctx.Connection.ClientCertificate is null)
|
|
{
|
|
ctx.Response.StatusCode = 401;
|
|
await ctx.Response.WriteAsync("client certificate required");
|
|
return;
|
|
}
|
|
await next();
|
|
});
|
|
|
|
var root = app.Environment.ContentRootPath;
|
|
var imagesDir = Path.Combine(root, "Images"); // store ONLY floppy images here (per-tag)
|
|
var uploadsDir = Path.Combine(root, "Uploaded"); // optional archive of posted volumes
|
|
Directory.CreateDirectory(imagesDir);
|
|
Directory.CreateDirectory(uploadsDir);
|
|
|
|
// Load FF.CFG from disk (mounted file). Fallback to a sane default if missing.
|
|
string FfCfgText = LoadFfCfg(Path.Combine(root, "FF.CFG")) ??
|
|
"host = acorn\r\n" +
|
|
"interface = ibmpc\r\n" +
|
|
"index-suppression = no\r\n" +
|
|
"sound = on\r\n" +
|
|
"sound-volume = 19\r\n" +
|
|
"display-type = none\r\n";
|
|
|
|
// GET /api/volume?id=<hex64> -> build tiny FAT12 in memory containing: [<image file>, FF.CFG]
|
|
app.MapGet("/api/volume", async ([FromQuery] string? id) =>
|
|
{
|
|
if (!IsHex64(id)) return Results.BadRequest("id required (hex, up to 16 chars)");
|
|
|
|
var imgPath = FindImage(imagesDir, id!);
|
|
if (imgPath is null) return Results.NotFound($"No image for id {id}");
|
|
|
|
byte[] payload = await File.ReadAllBytesAsync(imgPath);
|
|
string payloadFileName = Path.GetFileName(imgPath);
|
|
|
|
byte[] fat = BuildFat12TwoFiles(payload, payloadFileName, Encoding.ASCII.GetBytes(FfCfgText));
|
|
|
|
return Results.File(new MemoryStream(fat), "application/octet-stream",
|
|
fileDownloadName: $"{id!.ToLowerInvariant()}.fat.img", enableRangeProcessing: true);
|
|
});
|
|
|
|
// POST /api/volume?id=<hex64> -> receive modified FAT; extract image file; save back to Images/<id>.<ext>
|
|
app.MapPost("/api/volume", async ([FromQuery] string? id, HttpRequest req) =>
|
|
{
|
|
if (!IsHex64(id)) return Results.BadRequest("id required (hex, up to 16 chars)");
|
|
|
|
var ts = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
|
var uploadedPath = Path.Combine(uploadsDir, $"{id!.ToLowerInvariant()}-{ts}.fat.img");
|
|
await using (var fs = File.Create(uploadedPath))
|
|
await req.Body.CopyToAsync(fs);
|
|
|
|
try
|
|
{
|
|
await using var s = File.OpenRead(uploadedPath);
|
|
var (name, data) = ExtractFirstNonCfgRootFile(s);
|
|
if (name is null || data is null)
|
|
return Results.BadRequest("no floppy image found in volume");
|
|
|
|
var ext = Path.GetExtension(name);
|
|
if (string.IsNullOrEmpty(ext)) ext = ".img";
|
|
var outPath = Path.Combine(imagesDir, $"{id.ToLowerInvariant()}{ext.ToLowerInvariant()}");
|
|
await File.WriteAllBytesAsync(outPath, data);
|
|
return Results.Ok(new { ok = true, saved = outPath });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.BadRequest(new { error = "extract failed", detail = ex.Message });
|
|
}
|
|
});
|
|
|
|
app.Run();
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
static string? LoadFfCfg(string path)
|
|
{
|
|
try
|
|
{
|
|
if (File.Exists(path)) return File.ReadAllText(path);
|
|
return null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
static bool IsHex64(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s) || s.Length > 16) return false;
|
|
foreach (var c in s) if (!Uri.IsHexDigit(c)) return false;
|
|
return true;
|
|
}
|
|
|
|
static string? FindImage(string dir, string id)
|
|
{
|
|
var stem = id.ToLowerInvariant();
|
|
var exts = new[] { ".adf", ".img", ".st", ".dsk", ".ima", ".adl" };
|
|
foreach (var ext in exts)
|
|
{
|
|
var p = Path.Combine(dir, stem + ext);
|
|
if (File.Exists(p)) return p;
|
|
}
|
|
var any = Directory.EnumerateFiles(dir)
|
|
.FirstOrDefault(f => Path.GetFileNameWithoutExtension(f)
|
|
.Equals(stem, StringComparison.OrdinalIgnoreCase));
|
|
return any;
|
|
}
|
|
|
|
// Build tiny FAT12 with two root files: payloadName + FF.CFG
|
|
static byte[] BuildFat12TwoFiles(byte[] payload, string payloadName, byte[] ffCfg)
|
|
{
|
|
const int BPS = 512;
|
|
const int SPC = 1;
|
|
const int RSVD = 1;
|
|
const int FATS = 2;
|
|
const int ROOT_ENTS = 32;
|
|
const byte MEDIA = 0xF8;
|
|
|
|
int clus = BPS * SPC;
|
|
int clPayload = Math.Max(1, (payload.Length + clus - 1) / clus);
|
|
int clCfg = Math.Max(1, (ffCfg.Length + clus - 1) / clus);
|
|
int clTotal = clPayload + clCfg;
|
|
|
|
int fatEntries = clTotal + 2;
|
|
int fatBytes = (fatEntries * 3 + 1) / 2;
|
|
int spFAT = (fatBytes + BPS - 1) / BPS;
|
|
|
|
int rootSecs = ((ROOT_ENTS * 32) + (BPS - 1)) / BPS;
|
|
int dataSecs = clTotal * SPC;
|
|
int totalSecs = RSVD + FATS * spFAT + rootSecs + dataSecs;
|
|
if (totalSecs >= 0x10000) throw new InvalidOperationException("volume too large");
|
|
|
|
using var ms = new MemoryStream(totalSecs * BPS);
|
|
var bw = new BinaryWriter(ms, Encoding.ASCII, leaveOpen:true);
|
|
|
|
byte[] bs = new byte[512];
|
|
bs[0]=0xEB; bs[1]=0x3C; bs[2]=0x90;
|
|
Encoding.ASCII.GetBytes("MSDOS5.0").CopyTo(bs,3);
|
|
WriteLE16(bs,11,(ushort)BPS);
|
|
bs[13]=(byte)SPC;
|
|
WriteLE16(bs,14,(ushort)RSVD);
|
|
bs[16]=(byte)FATS;
|
|
WriteLE16(bs,17,(ushort)ROOT_ENTS);
|
|
WriteLE16(bs,19,(ushort)totalSecs);
|
|
bs[21]=MEDIA;
|
|
WriteLE16(bs,22,(ushort)spFAT);
|
|
WriteLE16(bs,24,32); WriteLE16(bs,26,64);
|
|
WriteLE32(bs,28,0);
|
|
bs[36]=0x80; bs[38]=0x29; WriteLE32(bs,39,0x1234ABCD);
|
|
Encoding.ASCII.GetBytes("GOTEK VOL ").CopyTo(bs,43);
|
|
Encoding.ASCII.GetBytes("FAT12 ").CopyTo(bs,54);
|
|
bs[510]=0x55; bs[511]=0xAA;
|
|
bw.Write(bs);
|
|
|
|
int fatAligned = spFAT * BPS;
|
|
byte[] fat = new byte[fatAligned];
|
|
fat[0]=MEDIA; fat[1]=0xFF; fat[2]=0xFF;
|
|
|
|
ushort firstPayload=2;
|
|
ushort firstCfg=(ushort)(firstPayload+clPayload);
|
|
|
|
for(int i=0;i<clPayload;i++){
|
|
ushort cl=(ushort)(firstPayload+i);
|
|
ushort val=(ushort)(i==clPayload-1?0x0FFF:cl+1);
|
|
SetFAT12(fat,cl,val);
|
|
}
|
|
for(int i=0;i<clCfg;i++){
|
|
ushort cl=(ushort)(firstCfg+i);
|
|
ushort val=(ushort)(i==clCfg-1?0x0FFF:cl+1);
|
|
SetFAT12(fat,cl,val);
|
|
}
|
|
for(int f=0;f<FATS;f++) bw.Write(fat);
|
|
|
|
byte[] root = new byte[rootSecs*BPS];
|
|
var n1 = To83(payloadName);
|
|
Array.Copy(n1,0,root,0,11);
|
|
root[11]=0x20; WriteLE16(root,26,firstPayload); WriteLE32(root,28,(uint)payload.Length);
|
|
var n2 = To83("FF.CFG");
|
|
Array.Copy(n2,0,root,32,11);
|
|
root[32+11]=0x20; WriteLE16(root,32+26,firstCfg); WriteLE32(root,32+28,(uint)ffCfg.Length);
|
|
bw.Write(root);
|
|
|
|
WriteData(bw,payload,clPayload,BPS);
|
|
WriteData(bw,ffCfg,clCfg,BPS);
|
|
|
|
bw.Flush();
|
|
return ms.ToArray();
|
|
|
|
static void WriteLE16(byte[] b,int o,ushort v){ b[o]=(byte)v; b[o+1]=(byte)(v>>8); }
|
|
static void WriteLE32(byte[] b,int o,uint v){ b[o]=(byte)v; b[o+1]=(byte)(v>>8); b[o+2]=(byte)(v>>16); b[o+3]=(byte)(v>>24); }
|
|
static void SetFAT12(byte[] fat, ushort cluster, ushort value){
|
|
int idx = (cluster*3)/2;
|
|
if((cluster&1)!=0){
|
|
ushort curr = (ushort)(fat[idx] | (fat[idx+1]<<8));
|
|
curr = (ushort)((curr & 0x00FF) | ((value & 0x0FFF) << 8));
|
|
fat[idx]=(byte)(curr&0xFF); fat[idx+1]=(byte)(curr>>8);
|
|
} else {
|
|
ushort curr = (ushort)(fat[idx] | (fat[idx+1]<<8));
|
|
curr = (ushort)((curr & 0xF000) | (value & 0x0FFF));
|
|
fat[idx]=(byte)(curr&0xFF); fat[idx+1]=(byte)(curr>>8);
|
|
}
|
|
}
|
|
static byte[] To83(string name){
|
|
name=name.Replace('\\','/'); if(name.Contains('/')) name=name[(name.LastIndexOf('/')+1)..];
|
|
var dot=name.LastIndexOf('.');
|
|
var basePart=dot>0?name[..dot]:name;
|
|
var ext=dot>0?name[(dot+1)..]:"";
|
|
basePart=basePart.ToUpperInvariant();
|
|
ext=ext.ToUpperInvariant();
|
|
if(string.IsNullOrEmpty(basePart)) basePart="FILE";
|
|
Span<byte> out11 = stackalloc byte[11]; out11.Fill((byte)' ');
|
|
Encoding.ASCII.GetBytes(basePart.Length>8?basePart[..8]:basePart).CopyTo(out11);
|
|
Encoding.ASCII.GetBytes(ext.Length>3?ext[..3]:ext).CopyTo(out11[8..]);
|
|
return out11.ToArray();
|
|
}
|
|
static void WriteData(BinaryWriter bw, byte[] data, int clusters, int bps){
|
|
int written=0; byte[] pad=new byte[bps];
|
|
for(int i=0;i<clusters;i++){
|
|
int take=Math.Min(bps, data.Length-written);
|
|
if(take>0) bw.Write(data, written, take);
|
|
if(take<bps) bw.Write(pad, 0, bps-take);
|
|
written += take;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Minimal FAT12 extractor (first non-FF.CFG file from root)
|
|
static (string? name, byte[]? data) ExtractFirstNonCfgRootFile(Stream s)
|
|
{
|
|
Span<byte> bpb = stackalloc byte[512];
|
|
if (s.Read(bpb) != 512) throw new InvalidOperationException("short BPB");
|
|
ushort bps = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(11, 2));
|
|
byte spc = bpb[13];
|
|
ushort rsv = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(14, 2));
|
|
byte fats = bpb[16];
|
|
ushort rootEnts = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(17, 2));
|
|
ushort spFat = BinaryPrimitives.ReadUInt16LittleEndian(bpb.Slice(22, 2));
|
|
if (bps != 512) throw new InvalidOperationException("bps!=512 unsupported");
|
|
|
|
uint rootSecs = (uint)((rootEnts * 32 + (bps - 1)) / bps);
|
|
uint firstFatSec = rsv;
|
|
uint rootDirSec = rsv + fats * spFat;
|
|
uint firstDataSec = rootDirSec + rootSecs;
|
|
|
|
s.Position = rootDirSec * 512;
|
|
byte[] root = new byte[rootSecs * 512];
|
|
if (s.Read(root, 0, root.Length) != root.Length) throw new InvalidOperationException("short root");
|
|
|
|
for (int i = 0; i < root.Length; i += 32)
|
|
{
|
|
byte first = root[i];
|
|
if (first == 0x00) break;
|
|
if (first == 0xE5) continue;
|
|
byte attr = root[i + 11];
|
|
if ((attr & 0x08) != 0) continue; // vol label
|
|
if ((attr & 0x10) != 0) continue; // dir
|
|
|
|
string name = Encoding.ASCII.GetString(root, i, 8).TrimEnd(' ');
|
|
string ext = Encoding.ASCII.GetString(root, i + 8, 3).TrimEnd(' ');
|
|
string full = ext.Length > 0 ? $"{name}.{ext}" : name;
|
|
if (string.Equals(full, "FF.CFG", StringComparison.OrdinalIgnoreCase)) continue;
|
|
|
|
ushort firstClus = BinaryPrimitives.ReadUInt16LittleEndian(root.AsSpan(i + 26, 2));
|
|
uint size = BinaryPrimitives.ReadUInt32LittleEndian(root.AsSpan(i + 28, 4));
|
|
|
|
s.Position = firstFatSec * 512;
|
|
byte[] fat = new byte[spFat * 512];
|
|
if (s.Read(fat, 0, fat.Length) != fat.Length) throw new InvalidOperationException("short FAT");
|
|
|
|
// FAT12 cluster walk
|
|
List<uint> clusters = new();
|
|
uint cl = firstClus;
|
|
while (cl >= 2 && cl < 0xFF8)
|
|
{
|
|
clusters.Add(cl);
|
|
uint idx = (uint)((cl * 3) / 2);
|
|
ushort entry = BitConverter.ToUInt16(fat, (int)idx);
|
|
uint next = (cl % 2 == 0) ? (uint)(entry & 0x0FFF) : (uint)(entry >> 4);
|
|
cl = next;
|
|
if (clusters.Count > 65536) break;
|
|
}
|
|
|
|
using var ms = new MemoryStream((int)size);
|
|
foreach (var c in clusters)
|
|
{
|
|
ulong sector = firstDataSec + (ulong)((c - 2) * spc);
|
|
s.Position = (long)sector * 512;
|
|
byte[] tmp = new byte[spc * 512];
|
|
int got = s.Read(tmp, 0, tmp.Length);
|
|
if (got <= 0) break;
|
|
int copy = (int)Math.Min((long)tmp.Length, (long)size - ms.Length);
|
|
ms.Write(tmp, 0, copy);
|
|
if (ms.Length >= size) break;
|
|
}
|
|
return (full, ms.ToArray());
|
|
}
|
|
return (null, null);
|
|
}
|