Stufe 1 (#764): Werkzeugeinstellungen, Sicherungszeitplan und Import als neue Instanz

- Werkzeugformulare aus den typisierten Einstellungsklassen (L8): Geheimnisse
  nur schreibend, nur geaenderte Schluessel werden ersetzt, Rechte bleiben
- Database.allowedTables las ein JSON-Array als rohen Text und haette es beim
  naechsten Speichern zerlegt
- Automatische Sicherung der Instanz in der Sicherungsansicht (L10)
- Sicherung hochladen und als neue Instanz einspielen (L11): Pfade der alten
  Maschine werden zurueckgesetzt, eine Kopie neben dem Original bekommt eine
  eigene ID, Autostart aus; Upload bis 4 GB
- api.js schickte eine Datei als "{}"; Dialoge schliessen beim Seitenwechsel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 14:13:37 +02:00
co-authored by Claude Opus 5.5
parent 8552755bb6
commit d2559c763a
17 changed files with 1027 additions and 28 deletions
+57
View File
@@ -102,6 +102,12 @@ public sealed record ManagerSettingsUpdateDto(string? DeploymentcenterUrl, strin
/// </summary>
internal static class ManagerEndpoints
{
/// <summary>
/// #764: Upper limit for an uploaded backup. Kestrel's default of 30 MB is far below a
/// backup with chat histories and state.db; without a limit, an upload could fill the disk.
/// </summary>
private const long MaxImportBytes = 4L * 1024 * 1024 * 1024;
// ─── Was den Manager selbst angeht ───
public static void MapVerwaltung(this WebApplication app)
@@ -382,6 +388,57 @@ internal static class ManagerEndpoints
}
});
// ─── Import a backup as a new instance (#764) ───
//
// The file comes as the raw request body, not as a form: the access token is a header,
// not a cookie, so there is no cross-site form to guard against, and a raw body needs
// no antiforgery exception. The source license key travels in a header and never in
// the address, where it would end up in logs.
verwaltung.MapPost("/backups/import", async (
HttpContext context, ManagerBackups backups, CancellationToken ct) =>
{
var name = context.Request.Query["name"].ToString();
if (string.IsNullOrWhiteSpace(name))
return Results.BadRequest(new OutcomeDto(false, "The new instance needs a name."));
if (context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
limit.MaxRequestBodySize = MaxImportBytes;
var upload = Path.Combine(Path.GetTempPath(), "clawd-import-" + Guid.NewGuid().ToString("N") + ".bin");
try
{
await using (var file = File.Create(upload))
await context.Request.Body.CopyToAsync(file, ct);
if (new FileInfo(upload).Length == 0)
return Results.BadRequest(new OutcomeDto(false, "The upload was empty."));
var result = await backups.ImportAsNewInstanceAsync(
upload, name,
context.Request.Headers["X-Source-License-Key"].ToString(),
context.Request.Headers["X-Backup-Passphrase"].ToString(),
CancellationToken.None);
if (result.KeyNeeded)
return Results.Json(new KeyNeededDto(false, result.Message, "fremde_lizenz"),
statusCode: StatusCodes.Status422UnprocessableEntity);
return result.Ok
? Results.Ok(new OutcomeDto(true, result.Message))
: Results.BadRequest(new OutcomeDto(false, result.Message));
}
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex) when (ex.StatusCode == StatusCodes.Status413PayloadTooLarge)
{
return Results.Json(new OutcomeDto(false, $"The backup is larger than {MaxImportBytes / (1024 * 1024 * 1024)} GB."),
statusCode: StatusCodes.Status413PayloadTooLarge);
}
finally
{
try { File.Delete(upload); }
catch (IOException) { /* temp file; the next cleanup of the temp folder takes it */ }
}
});
// ─── License and manager settings (#764) ───
verwaltung.MapGet("/license", (ManagerCore manager) =>