AquaCubeIT.NetFloppy/AquaCubeIT.Service.NetFloppy/Program.cs

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);
}