DELETE /api/images/{id}: удаление неиспользуемой картинки своего workspace (строка + файл), 409 со списком страниц, если упомянута
This commit is contained in:
@@ -52,6 +52,7 @@
|
||||
| `GET` | `/api/search?q=…&limit=30` | `{ results: [{ id, kind, title, snippet, path }] }` — FTS5 по названию и тексту, префиксный поиск по словам; совпадения в `snippet` обрамлены `\u0002…\u0003` |
|
||||
| `POST` | `/api/images` | `multipart/form-data`: `file` (PNG/JPEG/GIF/WebP, до 10 МБ), `pageId?` → `201 { id, url }` |
|
||||
| `GET` | `/api/images/{id}` | картинка; нужен токен и доступ к workspace картинки |
|
||||
| `DELETE` | `/api/images/{id}` | удалить картинку своего workspace (`X-Wiki-Workspace`): строку в базе и файл → `200 { id, deleted: true }`. Нет такой картинки в этом workspace — `404`. Картинка упомянута на странице (`/api/images/{id}` в тексте любой страницы любого workspace) — `409 { error, pages: [{ id, workspace, title }] }`, ничего не удаляется; `title` = `null` для страниц чужого workspace |
|
||||
|
||||
```ts
|
||||
type Node = { id, parentId: string|null, kind: "folder"|"page", title, version: number,
|
||||
|
||||
96
backend/Wiki.Api.Tests/ImageDeleteTests.cs
Normal file
96
backend/Wiki.Api.Tests/ImageDeleteTests.cs
Normal 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, $"", _u);
|
||||
var foreign = _s.Create(Other, "page", "Секрет", null, $"текст  ще", _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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user