// 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= -> build tiny FAT12 in memory containing: [, 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= -> receive modified FAT; extract image file; save back to Images/. 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>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 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;i0) bw.Write(data, written, take); if(take 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 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); }