Files
petfeed/src/lib/data-export-storage.ts
T
admin 5e2a11e44f feat(admin): route GDPR export downloads through pawfeed.org instead of Supabase (Gitea #6 follow-up)
Replaces the emailed Supabase signed URL with a bespoke DataExportToken
DB record, delivered via a new /api/gdpr-export/[token] route that
streams the file server-side. The storage backend is now an internal
implementation detail; every link the user sees reads pawfeed.org.
2026-08-11 14:21:39 +02:00

68 lines
2.5 KiB
TypeScript

import "server-only";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
/**
* Storage for GDPR Art. 15 data-export bundles — deliberately a SEPARATE,
* private bucket from supabase-storage.ts's "pet-avatars" (which is public,
* fine for avatars/post photos, wrong for a personal-data dump).
*
* No Supabase signed URL is ever handed to the end user — the download link
* emailed out is a pawfeed.org-hosted token (see DataExportToken / the
* /api/gdpr-export/[token] route), which fetches the bytes from here
* server-side using the service-role client and streams them through.
*/
let _client: SupabaseClient | null = null;
function getClient(): SupabaseClient {
if (!_client) {
_client = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
}
return _client;
}
export const EXPORTS_BUCKET = "data-exports";
/** Idempotent — creates the private bucket if it doesn't exist yet. */
async function ensureBucketExists(): Promise<void> {
const { error } = await getClient().storage.createBucket(EXPORTS_BUCKET, {
public: false,
});
// "already exists" is expected on every call after the first — not an error.
if (error && !error.message.toLowerCase().includes("already exists")) {
throw new Error(`Storage error creating exports bucket: ${error.message}`);
}
}
/**
* Uploads a JSON export bundle. The object itself is left in Storage
* afterward — no automated cleanup yet (acceptable for now: content is inert
* JSON, unreachable without a valid DataExportToken).
*/
export async function uploadExport(key: string, jsonContent: string): Promise<void> {
await ensureBucketExists();
const { error } = await getClient()
.storage.from(EXPORTS_BUCKET)
.upload(key, jsonContent, { contentType: "application/json", upsert: true });
if (error) throw new Error(`Storage error uploading export: ${error.message}`);
}
/**
* Downloads an export bundle's raw bytes using the service-role client —
* called only from the /api/gdpr-export/[token] route after that route has
* already validated the caller's DataExportToken. Never exposes a Supabase
* URL to the browser.
*/
export async function downloadExport(key: string): Promise<Buffer> {
const { data, error } = await getClient().storage.from(EXPORTS_BUCKET).download(key);
if (error || !data) {
throw new Error(`Storage error downloading export: ${error?.message ?? "unknown"}`);
}
return Buffer.from(await data.arrayBuffer());
}