From 829d785c4ab08645a4b96816fd1d43e59942ed73 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sun, 1 Mar 2026 09:20:22 +0000 Subject: [PATCH] feat: add download helper and Payload API client with token caching Co-Authored-By: Claude Opus 4.6 --- src/payload/client.ts | 181 ++++++++++++++++++++++++++++++++++++++++++ src/utils/download.ts | 38 +++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/payload/client.ts create mode 100644 src/utils/download.ts diff --git a/src/payload/client.ts b/src/payload/client.ts new file mode 100644 index 0000000..fafeaf6 --- /dev/null +++ b/src/payload/client.ts @@ -0,0 +1,181 @@ +import { config } from '../config.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('PayloadClient'); + +interface MediaUploadOptions { + alt: string; + tenantId: number; + caption?: string; +} + +interface MediaDoc { + id: number; + url: string; + filename: string; + alt: string; + mimeType: string; + filesize: number; + width?: number; + height?: number; + sizes?: Record; + createdAt: string; +} + +interface MediaListResponse { + docs: MediaDoc[]; + totalDocs: number; +} + +interface TenantDoc { + id: number; + name: string; + slug: string; +} + +function decodeJwtExpiry(token: string): number { + const parts = token.split('.'); + if (parts.length !== 3) return 0; + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + return payload.exp || 0; +} + +class PayloadClient { + private token: string | null = null; + private tokenExpiry: number = 0; + private readonly apiUrl: string; + + constructor() { + this.apiUrl = config.payload.apiUrl; + } + + async login(): Promise { + log.info('Logging in to Payload CMS...'); + const response = await fetch(`${this.apiUrl}/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: config.payload.email, + password: config.payload.password, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Payload login failed (${response.status}): ${text}`); + } + + const data = await response.json(); + this.token = data.token; + this.tokenExpiry = decodeJwtExpiry(data.token); + log.info(`Login successful, token expires at ${new Date(this.tokenExpiry * 1000).toISOString()}`); + } + + async getToken(): Promise { + const now = Math.floor(Date.now() / 1000); + const buffer = 300; // 5 min buffer + + if (!this.token || this.tokenExpiry <= now + buffer) { + await this.login(); + } + + return this.token!; + } + + private async authFetch(url: string, init: RequestInit = {}, retry = true): Promise { + const token = await this.getToken(); + const headers = new Headers(init.headers); + headers.set('Authorization', `JWT ${token}`); + + const response = await fetch(url, { ...init, headers }); + + if (response.status === 401 && retry) { + log.warn('Got 401, re-authenticating...'); + this.token = null; + return this.authFetch(url, init, false); + } + + return response; + } + + async uploadMedia(file: Buffer, filename: string, mimeType: string, options: MediaUploadOptions): Promise { + const formData = new FormData(); + formData.append('alt', options.alt); + if (options.caption) formData.append('caption', options.caption); + formData.append('tenant', String(options.tenantId)); + + const arrayBuffer = new ArrayBuffer(file.byteLength); + new Uint8Array(arrayBuffer).set(file); + const blob = new Blob([arrayBuffer], { type: mimeType }); + formData.append('file', blob, filename); + + log.info(`Uploading ${filename} (${(file.byteLength / 1024).toFixed(1)} KB) to tenant ${options.tenantId}`); + + const response = await this.authFetch(`${this.apiUrl}/media`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed (${response.status}): ${text}`); + } + + const data = await response.json(); + log.info(`Upload successful: ID ${data.doc.id}, ${data.doc.filename}`); + return data.doc; + } + + async listMedia(tenantId: number, limit = 5): Promise { + const params = new URLSearchParams({ + 'where[tenant][equals]': String(tenantId), + sort: '-createdAt', + limit: String(limit), + }); + + const response = await this.authFetch(`${this.apiUrl}/media?${params}`); + if (!response.ok) { + throw new Error(`List media failed (${response.status})`); + } + + return response.json(); + } + + async deleteMedia(mediaId: number): Promise { + const response = await this.authFetch(`${this.apiUrl}/media/${mediaId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Delete media failed (${response.status})`); + } + + log.info(`Deleted media ID ${mediaId}`); + } + + async listTenants(): Promise { + const response = await this.authFetch(`${this.apiUrl}/tenants?limit=50`); + if (!response.ok) { + throw new Error(`List tenants failed (${response.status})`); + } + + const data = await response.json(); + return data.docs; + } + + async checkHealth(): Promise { + try { + const response = await this.authFetch(`${this.apiUrl}/users/me`); + return response.ok; + } catch { + return false; + } + } + + getTokenExpiry(): Date | null { + return this.tokenExpiry > 0 ? new Date(this.tokenExpiry * 1000) : null; + } +} + +export const payloadClient = new PayloadClient(); +export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc }; diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..24239b0 --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,38 @@ +import { createLogger } from './logger.js'; + +const log = createLogger('Download'); +const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB + +export async function downloadFile(url: string): Promise<{ buffer: Buffer; mimeType: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Download failed: HTTP ${response.status}`); + } + + const contentLength = Number(response.headers.get('content-length') || '0'); + if (contentLength > MAX_FILE_SIZE) { + throw new Error(`Datei zu groß (${(contentLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`); + } + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_FILE_SIZE) { + throw new Error(`Datei zu groß (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`); + } + + const mimeType = response.headers.get('content-type') || 'application/octet-stream'; + log.debug(`Downloaded ${arrayBuffer.byteLength} bytes, type: ${mimeType}`); + + return { buffer: Buffer.from(arrayBuffer), mimeType }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error('Download-Timeout: Server hat nicht innerhalb von 30 Sekunden geantwortet'); + } + throw error; + } finally { + clearTimeout(timeout); + } +}