feat(community): Datenschicht für Galerie, API und Veröffentlichen
- gallery.ts: Galerie aus Appwrite lesen (Abfragen für Liste, Suche, Cursor, Admin-Filter), getGalleryCard (404 und ungültige ID ergeben null), eigene Veröffentlichungen, Meldungen, Bilder - api.ts: Aufrufe von aircard-api mit JWT (Cache je Nutzer, 10 Minuten, geleert bei Auth-Änderung, genau eine Wiederholung bei 401), ApiError mit code, Netz-/Proxy-Fehler als 503 - publish.ts: rendern, zwei Uploads in gallery-images, API; bei Fehler genau die zwei Dateien dieses Versuchs löschen; normalizeTags - cards.ts: eigenständiges fetchFile(bucketId, fileId); CardsApi.fetchFile bleibt Hülle - appwrite.ts: IDS für gallery, reports, gallery-images Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7bbe0a9fe6
commit
89c86fd578
9 files changed
+1576
-7
No files matched your search
@@ -5,8 +5,18 @@ import { Account, Client, Storage, TablesDB } from 'appwrite';
|
||||
export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
|
||||
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || '6ac2130a00289343d6d5';
|
||||
|
||||
/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */
|
||||
export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const;
|
||||
/**
|
||||
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
|
||||
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie.
|
||||
*/
|
||||
export const IDS = {
|
||||
database: 'aircard',
|
||||
table: 'cards',
|
||||
bucket: 'card-images',
|
||||
gallery: 'gallery',
|
||||
reports: 'reports',
|
||||
galleryBucket: 'gallery-images',
|
||||
} as const;
|
||||
|
||||
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
||||
export const account: Account = new Account(client);
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('./appwrite', () => ({
|
||||
import {
|
||||
appwriteCardsApi,
|
||||
createCardsService,
|
||||
fetchFile,
|
||||
normalizeName,
|
||||
ownerPermissions,
|
||||
toUploadFile,
|
||||
@@ -711,3 +712,52 @@ describe('appwriteCardsApi', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFile (eigenständig, beliebiger Bucket)', () => {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const prepare = () =>
|
||||
mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({
|
||||
uri: url.toString(),
|
||||
options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' },
|
||||
}));
|
||||
|
||||
it('holt die Datei aus dem übergebenen Bucket, mit Projekt-Header und Cookie', async () => {
|
||||
prepare();
|
||||
const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await fetchFile('gallery-images', 'f1');
|
||||
expect(result.type).toBe('image/png');
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/gallery-images/files/f1/view', {
|
||||
method: 'GET',
|
||||
headers: { 'X-Appwrite-Project': 'proj' },
|
||||
credentials: 'include',
|
||||
});
|
||||
});
|
||||
|
||||
it('maskiert Bucket- und Datei-ID im Pfad', async () => {
|
||||
prepare();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
|
||||
await fetchFile('a/b', '../x?y');
|
||||
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/a%2Fb/files/..%2Fx%3Fy/view');
|
||||
});
|
||||
|
||||
it('Fehlerantwort: AppwriteException mit Status und Typ des Servers', async () => {
|
||||
prepare();
|
||||
const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found' })));
|
||||
const err = await fetchFile('gallery-images', 'f1').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(AppwriteException);
|
||||
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
|
||||
});
|
||||
|
||||
it('CardsApi.fetchFile bleibt die Hülle über den Karten-Bucket', async () => {
|
||||
prepare();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
|
||||
await appwriteCardsApi().fetchFile('f1');
|
||||
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/f1/view');
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export interface SaveInput {
|
||||
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||
const NAME_MAX = 80;
|
||||
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||
export function normalizeName(name: string): string {
|
||||
@@ -193,11 +193,13 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
|
||||
export type CardsService = ReturnType<typeof createCardsService>;
|
||||
|
||||
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||
async function fetchFile(fileId: string): Promise<Blob> {
|
||||
/** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||
export async function fetchFile(bucketId: string, fileId: string): Promise<Blob> {
|
||||
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
|
||||
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
|
||||
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
|
||||
const url = new URL(
|
||||
`${APPWRITE_ENDPOINT}/storage/buckets/${encodeURIComponent(bucketId)}/files/${encodeURIComponent(fileId)}/view`,
|
||||
);
|
||||
const { uri, options } = client.prepareRequest('GET', url);
|
||||
const res = await fetch(uri, options);
|
||||
if (!res.ok) {
|
||||
@@ -231,7 +233,8 @@ export function appwriteCardsApi(): CardsApi {
|
||||
deleteFile: async (fileId) => {
|
||||
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
|
||||
},
|
||||
fetchFile,
|
||||
// Die privaten Karten liegen im Karten-Bucket; die Galerie holt ihre Bilder über `fetchFile` mit eigenem Bucket.
|
||||
fetchFile: (fileId) => fetchFile(IDS.bucket, fileId),
|
||||
createRow: (data, permissions) =>
|
||||
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
|
||||
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
|
||||
|
||||
Reference in new issue
Block a user