DELETE /api/images/{id}: удаление неиспользуемой картинки своего workspace (строка + файл), 409 со списком страниц, если упомянута

This commit is contained in:
EugeneTes
2026-10-16 14:22:37 +00:00
parent 8a19df7d23
commit c474bb3607
4 changed files with 152 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
using Microsoft.Extensions.Configuration;
using Wiki.Api;
namespace Wiki.Api.Tests;
/// <summary>DELETE /api/images/{id}: строка и файл удаляются; чужой workspace — 404; картинка на странице — 409.</summary>
public class ImageDeleteTests : IDisposable
{
private const string Ws = "wsmv5l7d425re160";
private const string Other = "wsother000000000";
private readonly string _dir = Path.Combine(Path.GetTempPath(), "wiki-tests-" + Guid.NewGuid().ToString("N"));
private readonly WikiUser _u = new("u-1", "u@test", "U", 0);
private readonly Db _db;
private readonly Store _s;
public ImageDeleteTests()
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Storage:DataDir"] = _dir,
}).Build();
_db = new Db(cfg); _db.Init();
_s = new Store(_db);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, true); } catch (IOException) { }
}
private string Upload(string ws)
{
var id = Store.NewId();
File.WriteAllBytes(Path.Combine(_db.ImagesDir, id + ".png"), [0x89, 0x50, 0x4E, 0x47]);
_s.AddImage(id, ws, null, "image/png", 4, id + ".png", _u);
return id;
}
private bool FileExists(string id) => File.Exists(Path.Combine(_db.ImagesDir, id + ".png"));
[Fact]
public void Unused_DeletesRowAndFile()
{
var id = Upload(Ws);
var (deleted, usedIn) = _s.DeleteImage(Ws, id);
Assert.True(deleted);
Assert.Empty(usedIn);
Assert.Null(_s.GetImage(id));
Assert.False(FileExists(id));
}
[Fact]
public void OtherWorkspace_404_AndNothingDeleted()
{
var id = Upload(Other);
var e = Assert.Throws<WikiError>(() => _s.DeleteImage(Ws, id));
Assert.Equal(404, e.Status);
Assert.NotNull(_s.GetImage(id));
Assert.True(FileExists(id));
}
[Fact]
public void Unknown_404()
{
var e = Assert.Throws<WikiError>(() => _s.DeleteImage(Ws, "0000000000000000"));
Assert.Equal(404, e.Status);
}
[Fact]
public void UsedOnPages_ListsThem_AndNothingDeleted()
{
var id = Upload(Ws);
var mine = _s.Create(Ws, "page", "Моя", null, $"![x](/api/images/{id})", _u);
var foreign = _s.Create(Other, "page", "Секрет", null, $"текст ![](/api/images/{id}) ще", _u);
_s.Create(Ws, "page", "Інша", null, "без картинок", _u);
var (deleted, usedIn) = _s.DeleteImage(Ws, id);
Assert.False(deleted);
Assert.Equal(2, usedIn.Count);
Assert.Contains(new ImageUsage(mine.Id, Ws, "Моя"), usedIn);
Assert.Contains(new ImageUsage(foreign.Id, Other, null), usedIn); // название чужой страницы скрыто
Assert.NotNull(_s.GetImage(id));
Assert.True(FileExists(id));
}
[Fact]
public void MissingFile_StillDeletesRow()
{
var id = Upload(Ws);
File.Delete(Path.Combine(_db.ImagesDir, id + ".png"));
Assert.True(_s.DeleteImage(Ws, id).Deleted);
Assert.Null(_s.GetImage(id));
}
}

View File

@@ -241,6 +241,15 @@ api.MapGet("/images/{id}", async (HttpContext c, Store s, Db db, SupabaseAuth sa
return Results.File(path, img.Mime);
});
// Удалить можно только картинку своего workspace (X-Wiki-Workspace) и только если её нет ни на одной странице.
api.MapDelete("/images/{id}", (HttpContext c, Store s, string id) =>
{
var (deleted, usedIn) = s.DeleteImage(Ws(c), id);
return deleted
? Results.Ok(new { id, deleted = true })
: Results.Json(new { error = "зображення використовується на сторінках", pages = usedIn }, statusCode: 409);
});
app.MapFallback("/api/{**rest}", () => Results.NotFound(new { error = "немає такого методу API" }));
app.MapFallbackToFile("index.html", new StaticFileOptions
{

View File

@@ -20,6 +20,9 @@ public sealed record SearchHit(string Id, string Kind, string Title, string Snip
public sealed record ImageRow(string Id, string WorkspaceId, string Mime, string FileName);
/// <summary>Страница, где упомянута картинка. Title = null для страниц чужого workspace.</summary>
public sealed record ImageUsage(string Id, string Workspace, string? Title);
public sealed class WikiError(int status, string message) : Exception(message)
{
public int Status { get; } = status;
@@ -278,6 +281,49 @@ public sealed partial class Store(Db db)
return r.Read() ? new ImageRow(r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3)) : null;
}
/// <summary>
/// Удаляет картинку своего workspace: строку в images и файл на томе.
/// Если на неё ссылается хоть одна страница (любого workspace) — 409 и список этих страниц, ничего не удаляется.
/// </summary>
public (bool Deleted, List<ImageUsage> UsedIn) DeleteImage(string ws, string id)
{
using var c = db.Open();
using var tx = c.BeginTransaction();
string fileName;
using (var cmd = c.CreateCommand())
{
cmd.CommandText = "SELECT file_name FROM images WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
fileName = cmd.ExecuteScalar() as string ?? throw new WikiError(404, "зображення не знайдено");
}
var used = new List<ImageUsage>();
using (var cmd = c.CreateCommand())
{
// id — 16 hex-символов фиксированной длины, поэтому подстрока не совпадёт с другим id.
cmd.CommandText = "SELECT id, workspace_id, title FROM nodes WHERE instr(content, $ref) > 0 ORDER BY workspace_id, title";
cmd.Parameters.AddWithValue("$ref", "/api/images/" + id);
using var r = cmd.ExecuteReader();
while (r.Read())
{
var own = r.GetString(1) == ws;
// Названия страниц чужого workspace не раскрываем.
used.Add(new ImageUsage(r.GetString(0), r.GetString(1), own ? r.GetString(2) : null));
}
}
if (used.Count > 0) return (false, used);
using (var cmd = c.CreateCommand())
{
cmd.CommandText = "DELETE FROM images WHERE id=$id AND workspace_id=$ws";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ws", ws);
cmd.ExecuteNonQuery();
}
tx.Commit();
File.Delete(System.IO.Path.Combine(db.ImagesDir, System.IO.Path.GetFileName(fileName)));
return (true, used);
}
private static void EnsureFolder(SqliteConnection c, string ws, string id)
{
using var cmd = c.CreateCommand();