fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden
Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cd55d4c891
commit
7bbe0a9fe6
6 files changed
+235
-63
No files matched your search
+75
-38
@@ -5,7 +5,8 @@
|
||||
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
|
||||
*
|
||||
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
|
||||
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket
|
||||
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket /
|
||||
* Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet
|
||||
* 409 schon veröffentlicht · 201 {id}
|
||||
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
|
||||
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
||||
@@ -26,7 +27,7 @@
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
|
||||
const { AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
|
||||
|
||||
const TABLE_GALLERY = 'gallery';
|
||||
const TABLE_REPORTS = 'reports';
|
||||
@@ -37,6 +38,8 @@ const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
|
||||
const HIDE_AT_REPORTS = 3;
|
||||
const REPORT_LIST_LIMIT = 100;
|
||||
const OWNER_ROWS_PAGE = 100;
|
||||
const OWNER_ROWS_MAX_PAGES = 100;
|
||||
const REPORT_DELETE_ROUNDS = 50;
|
||||
const RATE_LIMIT = 10;
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
@@ -61,6 +64,7 @@ const MSG = {
|
||||
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
|
||||
fileSize: 'Das Bild ist größer als 5 MB.',
|
||||
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
|
||||
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
|
||||
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
|
||||
badRequest: 'Ungültige Anfrage.',
|
||||
badCardId: 'Ungültige Karten-ID.',
|
||||
@@ -446,12 +450,37 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
|
||||
throw new HttpError(403, MSG.foreignFile);
|
||||
}
|
||||
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
|
||||
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
|
||||
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
|
||||
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize);
|
||||
}
|
||||
|
||||
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
|
||||
|
||||
/**
|
||||
* Datei-IDs, die schon eine Galerie-Zeile dieses Autors verwendet (Bild oder Vorschau, auch ausgeblendete Karten).
|
||||
* Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
|
||||
* Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit
|
||||
* Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab).
|
||||
*/
|
||||
async function fileIdsInUse(ownerId) {
|
||||
const used = new Set();
|
||||
let cursor = null;
|
||||
for (let page = 0; page < OWNER_ROWS_MAX_PAGES; page += 1) {
|
||||
const queries = [queryEqual('ownerId', [ownerId]), queryLimit(OWNER_ROWS_PAGE)];
|
||||
if (cursor !== null) queries.push(queryCursorAfter(cursor));
|
||||
const rows = await appwrite.listRows(TABLE_GALLERY, queries);
|
||||
for (const row of rows) {
|
||||
used.add(row.imageFileId);
|
||||
used.add(row.previewFileId);
|
||||
}
|
||||
if (rows.length < OWNER_ROWS_PAGE) return used;
|
||||
cursor = rows[rows.length - 1].$id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Rechte beider Dateien. Wartet auf beide Aufrufe, auch wenn einer scheitert (die Sperre der Karte darf
|
||||
* nicht freigegeben werden, solange noch ein PUT unterwegs ist), und wirft danach den ersten Fehler.
|
||||
@@ -504,43 +533,51 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
checkFile(image, user.id);
|
||||
checkFile(preview, user.id);
|
||||
|
||||
const id = galleryRowId(user.id, data.sourceCardId);
|
||||
try {
|
||||
await appwrite.createRow(
|
||||
TABLE_GALLERY,
|
||||
id,
|
||||
{
|
||||
title: data.title,
|
||||
issuer: data.issuer,
|
||||
tags: data.tags.join(','),
|
||||
displayName: data.displayName,
|
||||
imageFileId: data.imageFileId,
|
||||
previewFileId: data.previewFileId,
|
||||
sourceCardId: data.sourceCardId,
|
||||
ownerId: user.id,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
searchText: buildSearchText(data),
|
||||
},
|
||||
visibleRowPermissions(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished);
|
||||
throw err;
|
||||
}
|
||||
// Je Autor nacheinander: Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
|
||||
return withLock(`owner:${user.id}`, async () => {
|
||||
const used = await fileIdsInUse(user.id);
|
||||
if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) {
|
||||
throw new HttpError(403, MSG.filesTaken);
|
||||
}
|
||||
|
||||
// Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen).
|
||||
// Erst nach dem Anlegen der Zeile: ein 409 darf die Rechte einer schon veröffentlichten (oder ausgeblendeten)
|
||||
// Karte nie anfassen. Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||
try {
|
||||
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
|
||||
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
|
||||
} catch (err) {
|
||||
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
say(`card ${id} published`);
|
||||
return { status: 201, body: { id } };
|
||||
const id = galleryRowId(user.id, data.sourceCardId);
|
||||
try {
|
||||
await appwrite.createRow(
|
||||
TABLE_GALLERY,
|
||||
id,
|
||||
{
|
||||
title: data.title,
|
||||
issuer: data.issuer,
|
||||
tags: data.tags.join(','),
|
||||
displayName: data.displayName,
|
||||
imageFileId: data.imageFileId,
|
||||
previewFileId: data.previewFileId,
|
||||
sourceCardId: data.sourceCardId,
|
||||
ownerId: user.id,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
searchText: buildSearchText(data),
|
||||
},
|
||||
visibleRowPermissions(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen).
|
||||
// Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an.
|
||||
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||
try {
|
||||
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
|
||||
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
|
||||
} catch (err) {
|
||||
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
say(`card ${id} published`);
|
||||
return { status: 201, body: { id } };
|
||||
});
|
||||
}
|
||||
|
||||
async function withdraw({ req, raw }) {
|
||||
|
||||
Reference in new issue
Block a user