feat: add download helper and Payload API client with token caching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-03-01 09:20:22 +00:00
parent a58d6f31fa
commit 829d785c4a
2 changed files with 219 additions and 0 deletions

181
src/payload/client.ts Normal file
View file

@ -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<string, { url?: string; width?: number; height?: number }>;
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<void> {
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<string> {
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<Response> {
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<MediaDoc> {
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<MediaListResponse> {
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<void> {
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<TenantDoc[]> {
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<boolean> {
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 };

38
src/utils/download.ts Normal file
View file

@ -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);
}
}