From 2d102bd318fc310e402f7e4677992f6ac3d0c717 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Tue, 18 Aug 2026 18:00:37 +0200 Subject: [PATCH 1/3] removed presigned link by email on data export --- .../account/ProfileSettings.module.css | 43 +++++++ .../dashboard/account/ProfileSettings.tsx | 77 ++++++++++-- messages/de.json | 14 ++- messages/en.json | 14 ++- messages/es.json | 14 ++- messages/fr.json | 14 ++- messages/ja.json | 14 ++- messages/ko.json | 14 ++- messages/pl.json | 14 ++- messages/zh.json | 14 ++- prisma/schema.prisma | 2 +- src/app/api/users/export/download/route.ts | 37 ++++++ src/app/api/users/export/route.ts | 17 ++- src/lib/adapters/screenplay-adapter.ts | 74 +---------- src/lib/mail/mail.ts | 12 +- src/lib/s3.ts | 3 - src/lib/utils/api-utils.ts | 5 + src/lib/utils/hooks.ts | 17 ++- src/lib/utils/requests.ts | 4 + src/lib/utils/save-file.ts | 91 ++++++++++++++ src/lib/utils/types.ts | 16 +++ .../repository/data-export-repository.ts | 47 ++++++- src/server/service/gdpr-export-service.ts | 117 +++++++++++++++--- 23 files changed, 543 insertions(+), 131 deletions(-) create mode 100644 src/app/api/users/export/download/route.ts create mode 100644 src/lib/utils/save-file.ts diff --git a/components/dashboard/account/ProfileSettings.module.css b/components/dashboard/account/ProfileSettings.module.css index 552daa72..7a3cfd75 100644 --- a/components/dashboard/account/ProfileSettings.module.css +++ b/components/dashboard/account/ProfileSettings.module.css @@ -160,6 +160,49 @@ cursor: default; } +/* Where the export currently stands: preparing, ready to download, or lapsed. + Sits under the request row, inside the same dashed container. */ +.exportStatus { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 12px 16px; + border-top: 1px solid var(--separator); + background-color: var(--secondary); +} + +.exportStatusText { + font-size: 0.85rem; + color: #666; +} + +.exportDownloadBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-shrink: 0; + background-color: transparent; + color: var(--primary-text); + border: 1px solid var(--primary-text); + padding: 6px 14px; + border-radius: 40px; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + transition: opacity 0.2s; +} + +.exportDownloadBtn:hover { + opacity: 0.7; +} + +.exportDownloadBtn:disabled { + opacity: 0.5; + cursor: default; +} + /* Shown in the delete-account dialog when a live subscription is at stake */ .subscriptionWarning { display: flex; diff --git a/components/dashboard/account/ProfileSettings.tsx b/components/dashboard/account/ProfileSettings.tsx index f48259d6..f7e5e5ae 100644 --- a/components/dashboard/account/ProfileSettings.tsx +++ b/components/dashboard/account/ProfileSettings.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { editUserInfo, deleteUser, requestDataExport } from "@src/lib/utils/requests"; +import { editUserInfo, deleteUser, requestDataExport, downloadDataExport } from "@src/lib/utils/requests"; import { signOut } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; import { useRouter } from "next/navigation"; @@ -14,7 +14,8 @@ import styles from "./ProfileSettings.module.css"; import dangerStyles from "../project/DangerZone.module.css"; import modal from "../../utils/ModalBtn.module.css"; import { ApiResponse } from "@src/lib/utils/api-utils"; -import { useUser } from "@src/lib/utils/hooks"; +import { useDataExport, useUser } from "@src/lib/utils/hooks"; +import { saveBlob } from "@src/lib/utils/save-file"; import { useLocale } from "@src/context/LocaleContext"; const PRESET_COLORS = [ @@ -30,6 +31,7 @@ const PRESET_COLORS = [ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; onDangerToggle: () => void }) => { const { user, mutate } = useUser(); + const { dataExport, mutate: mutateExport } = useDataExport(); const router = useRouter(); const t = useTranslations("profile"); const tCommon = useTranslations("common"); @@ -60,10 +62,23 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState(null); const [exportLoading, setExportLoading] = useState(false); + const [exportRequested, setExportRequested] = useState(false); + const [downloadLoading, setDownloadLoading] = useState(false); const [exportMessage, setExportMessage] = useState<{ type: "success" | "error"; text: string } | null>( null, ); + // The server is the authority on whether another export is allowed — a reload + // must not hand back a button the API would only answer with 429. `exportRequested` + // just covers the blink between the request landing and the state refetching. + const isExportBlocked = + exportLoading || exportRequested || !!dataExport?.canRequestAt || dataExport?.status === "PENDING"; + const exportExpiryDate = dataExport?.expiresAt + ? new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric" }).format( + new Date(dataExport.expiresAt), + ) + : ""; + // Sync state when settings load useEffect(() => { if (user && !initialized) { @@ -85,18 +100,22 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; setMessage(null); }; - // GDPR data-access request: the server bundles everything in the background - // and emails a download link, so the only feedback here is "check your inbox". + // GDPR data-access request. The server bundles the zip in the background and + // keeps it for 7 days; the panel below polls until it is ready to download, + // and the email that goes out is only a notification. const handleRequestExport = async () => { - if (exportLoading) return; + if (isExportBlocked) return; setExportLoading(true); setExportMessage(null); try { const res = await requestDataExport(); if (res.ok) { + setExportRequested(true); setExportMessage({ type: "success", text: t("exportRequested") }); } else if (res.status === 409) { setExportMessage({ type: "error", text: t("exportPending") }); + } else if (res.status === 429) { + setExportMessage({ type: "error", text: t("exportThrottled") }); } else { setExportMessage({ type: "error", text: t("exportFailed") }); } @@ -104,6 +123,31 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; setExportMessage({ type: "error", text: t("exportFailed") }); } finally { setExportLoading(false); + mutateExport(); + } + }; + + // Streamed through the API rather than linked to, so the archive is only ever + // handed to a request carrying this user's session. + const handleDownloadExport = async () => { + if (!dataExport?.id || downloadLoading) return; + setDownloadLoading(true); + setExportMessage(null); + try { + const res = await downloadDataExport(dataExport.id); + if (!res.ok) throw new Error("Download failed"); + + await saveBlob(await res.blob(), "scriptio-data-export.zip", { + label: t("exportArchive"), + extension: "zip", + }); + } catch { + // Most likely the archive lapsed while the panel was open — refetch so + // the state stops offering a download that no longer exists. + setExportMessage({ type: "error", text: t("exportDownloadFailed") }); + mutateExport(); + } finally { + setDownloadLoading(false); } }; @@ -171,12 +215,31 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; + {dataExport && dataExport.status !== "NONE" && ( +
+ + {dataExport.status === "PENDING" && t("exportStatePreparing")} + {dataExport.status === "READY" && + t("exportStateReady", { date: exportExpiryDate })} + {dataExport.status === "EXPIRED" && t("exportStateExpired")} + + {dataExport.status === "READY" && ( + + )} +
+ )} {exportMessage && (
diff --git a/messages/de.json b/messages/de.json index 3b3152f0..5afdd8b6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -239,12 +239,20 @@ "saving": "Speichern...", "dangerZoneTitle": "Gefahrenzone", "exportData": "Meine Daten exportieren", - "exportDataDesc": "Fordern Sie eine Kopie Ihrer persönlichen Daten an: Kontoinformationen, Einstellungen und Projektmitgliedschaften. Sie erhalten einen Download-Link per E-Mail, gültig für 7 Tage.", + "exportDataDesc": "Fordern Sie eine Kopie Ihrer persönlichen Daten an: Kontoinformationen, Einstellungen und Projektmitgliedschaften. Sie steht hier 7 Tage lang zum Download bereit.", "exportBtn": "Export anfordern", "exportRequesting": "Wird angefordert...", - "exportRequested": "Export gestartet — Sie erhalten in Kürze eine E-Mail mit einem Download-Link. Der Link ist 7 Tage gültig.", - "exportPending": "Ein Export wird bereits vorbereitet. Bitte warten Sie auf die E-Mail.", + "exportRequested": "Export gestartet — er steht gleich hier zum Download bereit und bleibt 7 Tage verfügbar.", + "exportPending": "Ein Export wird bereits vorbereitet. Er erscheint hier, sobald er fertig ist.", + "exportThrottled": "Sie haben kürzlich bereits einen Export angefordert. In einer Stunde können Sie einen neuen anfordern.", "exportFailed": "Der Export konnte nicht angefordert werden. Bitte versuchen Sie es erneut.", + "exportStatePreparing": "Ihr Export wird vorbereitet. Das dauert nur einen Moment.", + "exportStateReady": "Ihr Export steht bis zum {date} zum Download bereit.", + "exportStateExpired": "Ihr letzter Export ist abgelaufen. Fordern Sie einen neuen an, um Ihre Daten erneut herunterzuladen.", + "exportDownload": "Herunterladen", + "exportDownloading": "Wird heruntergeladen...", + "exportDownloadFailed": "Der Export konnte nicht heruntergeladen werden. Möglicherweise ist er abgelaufen — fordern Sie einen neuen an.", + "exportArchive": "ZIP-Archiv", "deleteAccount": "Konto löschen", "deleteAccountDesc": "Löschen Sie dauerhaft Ihr Konto und alle verknüpften Daten. Dies kann nicht rückgängig gemacht werden.", "deleteBtn": "Löschen", diff --git a/messages/en.json b/messages/en.json index 03428e2f..3f7f24e5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -238,12 +238,20 @@ "saving": "Saving...", "dangerZoneTitle": "Danger zone", "exportData": "Export my data", - "exportDataDesc": "Request a copy of your personal data: account information, settings and project memberships. You'll receive a download link by email, valid for 7 days.", + "exportDataDesc": "Request a copy of your personal data: account information, settings and project memberships. It stays available to download here for 7 days.", "exportBtn": "Request export", "exportRequesting": "Requesting...", - "exportRequested": "Export started — you'll receive an email with a download link shortly. The link stays valid for 7 days.", - "exportPending": "An export is already being prepared. Please wait for the email.", + "exportRequested": "Export started — it will be ready to download here in a moment, and stays available for 7 days.", + "exportPending": "An export is already being prepared. It will appear here once it's ready.", + "exportThrottled": "You already requested an export recently. You can request a new one in an hour.", "exportFailed": "Failed to request the export. Please try again.", + "exportStatePreparing": "Your export is being prepared. This only takes a moment.", + "exportStateReady": "Your export is ready to download until {date}.", + "exportStateExpired": "Your last export has expired. Request a new one to download your data again.", + "exportDownload": "Download", + "exportDownloading": "Downloading...", + "exportDownloadFailed": "Failed to download the export. It may have expired — try requesting a new one.", + "exportArchive": "ZIP archive", "deleteAccount": "Delete account", "deleteAccountDesc": "Permanently delete your account and all associated data. This cannot be undone.", "deleteBtn": "Delete", diff --git a/messages/es.json b/messages/es.json index b1c594c1..0344b141 100644 --- a/messages/es.json +++ b/messages/es.json @@ -238,12 +238,20 @@ "saving": "Guardando...", "dangerZoneTitle": "Zona de peligro", "exportData": "Exportar mis datos", - "exportDataDesc": "Solicita una copia de tus datos personales: información de la cuenta, configuración y membresías de proyectos. Recibirás un enlace de descarga por correo, válido durante 7 días.", + "exportDataDesc": "Solicita una copia de tus datos personales: información de la cuenta, configuración y membresías de proyectos. Estará disponible para descargar aquí durante 7 días.", "exportBtn": "Solicitar exportación", "exportRequesting": "Solicitando...", - "exportRequested": "Exportación iniciada — pronto recibirás un correo con un enlace de descarga. El enlace es válido durante 7 días.", - "exportPending": "Ya se está preparando una exportación. Espera el correo electrónico.", + "exportRequested": "Exportación iniciada — estará lista para descargar aquí en un momento y permanecerá disponible 7 días.", + "exportPending": "Ya se está preparando una exportación. Aparecerá aquí cuando esté lista.", + "exportThrottled": "Ya solicitaste una exportación recientemente. Podrás solicitar otra dentro de una hora.", "exportFailed": "No se pudo solicitar la exportación. Inténtalo de nuevo.", + "exportStatePreparing": "Tu exportación se está preparando. Solo tomará un momento.", + "exportStateReady": "Tu exportación está disponible para descargar hasta el {date}.", + "exportStateExpired": "Tu última exportación ha caducado. Solicita una nueva para descargar tus datos de nuevo.", + "exportDownload": "Descargar", + "exportDownloading": "Descargando...", + "exportDownloadFailed": "No se pudo descargar la exportación. Puede haber caducado: solicita una nueva.", + "exportArchive": "Archivo ZIP", "deleteAccount": "Eliminar cuenta", "deleteAccountDesc": "Elimina permanentemente tu cuenta y todos los datos asociados. Esto no se puede deshacer.", "deleteBtn": "Eliminar", diff --git a/messages/fr.json b/messages/fr.json index e8b5c9ef..34131384 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -239,12 +239,20 @@ "saving": "Enregistrement...", "dangerZoneTitle": "Zone dangereuse", "exportData": "Exporter mes données", - "exportDataDesc": "Demandez une copie de vos données personnelles : informations du compte, paramètres et participations aux projets. Vous recevrez un lien de téléchargement par e-mail, valide 7 jours.", + "exportDataDesc": "Demandez une copie de vos données personnelles : informations du compte, paramètres et participations aux projets. Elle reste téléchargeable ici pendant 7 jours.", "exportBtn": "Demander l'export", "exportRequesting": "Demande en cours...", - "exportRequested": "Export lancé — vous recevrez bientôt un e-mail avec un lien de téléchargement. Le lien reste valide 7 jours.", - "exportPending": "Un export est déjà en cours de préparation. Veuillez attendre l'e-mail.", + "exportRequested": "Export lancé — il sera téléchargeable ici dans un instant, et restera disponible 7 jours.", + "exportPending": "Un export est déjà en cours de préparation. Il apparaîtra ici une fois prêt.", + "exportThrottled": "Vous avez déjà demandé un export récemment. Vous pourrez en demander un nouveau dans une heure.", "exportFailed": "Échec de la demande d'export. Veuillez réessayer.", + "exportStatePreparing": "Votre export est en cours de préparation. Cela ne prend qu'un instant.", + "exportStateReady": "Votre export est téléchargeable jusqu'au {date}.", + "exportStateExpired": "Votre dernier export a expiré. Demandez-en un nouveau pour télécharger vos données.", + "exportDownload": "Télécharger", + "exportDownloading": "Téléchargement...", + "exportDownloadFailed": "Échec du téléchargement de l'export. Il a peut-être expiré — demandez-en un nouveau.", + "exportArchive": "Archive ZIP", "deleteAccount": "Supprimer le compte", "deleteAccountDesc": "Supprimez définitivement votre compte et toutes les données associées. Cette action est irréversible.", "deleteBtn": "Supprimer", diff --git a/messages/ja.json b/messages/ja.json index b8f11e31..13105f25 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -238,12 +238,20 @@ "saving": "保存中...", "dangerZoneTitle": "危険ゾーン", "exportData": "データをエクスポート", - "exportDataDesc": "個人データ(アカウント情報、設定、プロジェクトのメンバーシップ)のコピーをリクエストできます。ダウンロードリンク(7日間有効)がメールで届きます。", + "exportDataDesc": "個人データ(アカウント情報、設定、プロジェクトのメンバーシップ)のコピーをリクエストできます。ここから7日間ダウンロードできます。", "exportBtn": "エクスポートを申請", "exportRequesting": "申請中...", - "exportRequested": "エクスポートを開始しました。まもなくダウンロードリンクを記載したメールが届きます。リンクは7日間有効です。", - "exportPending": "エクスポートは既に準備中です。メールをお待ちください。", + "exportRequested": "エクスポートを開始しました。まもなくここからダウンロードできるようになり、7日間利用できます。", + "exportPending": "エクスポートは既に準備中です。準備ができるとここに表示されます。", + "exportThrottled": "最近エクスポートを申請済みです。1時間後に再度申請できます。", "exportFailed": "エクスポートの申請に失敗しました。もう一度お試しください。", + "exportStatePreparing": "エクスポートを準備しています。少しお待ちください。", + "exportStateReady": "エクスポートは {date} までダウンロードできます。", + "exportStateExpired": "前回のエクスポートは期限切れです。データを再度ダウンロードするには、新しく申請してください。", + "exportDownload": "ダウンロード", + "exportDownloading": "ダウンロード中...", + "exportDownloadFailed": "エクスポートをダウンロードできませんでした。期限が切れている可能性があります。新しく申請してください。", + "exportArchive": "ZIP アーカイブ", "deleteAccount": "アカウントを削除", "deleteAccountDesc": "アカウントとすべての関連データを完全に削除します。この操作は取り消せません。", "deleteBtn": "削除", diff --git a/messages/ko.json b/messages/ko.json index 764637ce..fe959ccf 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -238,12 +238,20 @@ "saving": "저장 중...", "dangerZoneTitle": "위험 구역", "exportData": "내 데이터 내보내기", - "exportDataDesc": "개인 데이터(계정 정보, 설정, 프로젝트 멤버십)의 사본을 요청합니다. 7일간 유효한 다운로드 링크가 이메일로 전송됩니다.", + "exportDataDesc": "개인 데이터(계정 정보, 설정, 프로젝트 멤버십)의 사본을 요청합니다. 여기에서 7일 동안 다운로드할 수 있습니다.", "exportBtn": "내보내기 요청", "exportRequesting": "요청 중...", - "exportRequested": "내보내기가 시작되었습니다. 곧 다운로드 링크가 포함된 이메일이 도착합니다. 링크는 7일간 유효합니다.", - "exportPending": "이미 내보내기가 준비 중입니다. 이메일을 기다려 주세요.", + "exportRequested": "내보내기가 시작되었습니다. 잠시 후 여기에서 다운로드할 수 있으며 7일 동안 유지됩니다.", + "exportPending": "이미 내보내기가 준비 중입니다. 준비되면 여기에 표시됩니다.", + "exportThrottled": "최근에 이미 내보내기를 요청했습니다. 1시간 후에 다시 요청할 수 있습니다.", "exportFailed": "내보내기 요청에 실패했습니다. 다시 시도해 주세요.", + "exportStatePreparing": "내보내기를 준비하고 있습니다. 잠시만 기다려 주세요.", + "exportStateReady": "{date}까지 내보내기를 다운로드할 수 있습니다.", + "exportStateExpired": "마지막 내보내기가 만료되었습니다. 데이터를 다시 다운로드하려면 새로 요청하세요.", + "exportDownload": "다운로드", + "exportDownloading": "다운로드 중...", + "exportDownloadFailed": "내보내기를 다운로드하지 못했습니다. 만료되었을 수 있으니 새로 요청해 주세요.", + "exportArchive": "ZIP 아카이브", "deleteAccount": "계정 삭제", "deleteAccountDesc": "계정과 모든 데이터를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다.", "deleteBtn": "삭제", diff --git a/messages/pl.json b/messages/pl.json index 8acc588f..2abe334c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -238,12 +238,20 @@ "saving": "Zapisywanie...", "dangerZoneTitle": "Strefa niebezpieczeństwa", "exportData": "Eksportuj moje dane", - "exportDataDesc": "Poproś o kopię swoich danych osobowych: informacji o koncie, ustawień i członkostw w projektach. Otrzymasz link do pobrania e-mailem, ważny przez 7 dni.", + "exportDataDesc": "Poproś o kopię swoich danych osobowych: informacji o koncie, ustawień i członkostw w projektach. Będzie ją można pobrać tutaj przez 7 dni.", "exportBtn": "Poproś o eksport", "exportRequesting": "Wysyłanie prośby...", - "exportRequested": "Eksport rozpoczęty — wkrótce otrzymasz e-mail z linkiem do pobrania. Link jest ważny przez 7 dni.", - "exportPending": "Eksport jest już przygotowywany. Poczekaj na e-mail.", + "exportRequested": "Eksport rozpoczęty — za chwilę będzie go można pobrać tutaj i pozostanie dostępny przez 7 dni.", + "exportPending": "Eksport jest już przygotowywany. Pojawi się tutaj, gdy będzie gotowy.", + "exportThrottled": "Niedawno poproszono już o eksport. Kolejny możesz zamówić za godzinę.", "exportFailed": "Nie udało się zażądać eksportu. Spróbuj ponownie.", + "exportStatePreparing": "Twój eksport jest przygotowywany. To zajmie tylko chwilę.", + "exportStateReady": "Eksport możesz pobrać do {date}.", + "exportStateExpired": "Twój ostatni eksport wygasł. Poproś o nowy, aby ponownie pobrać swoje dane.", + "exportDownload": "Pobierz", + "exportDownloading": "Pobieranie...", + "exportDownloadFailed": "Nie udało się pobrać eksportu. Mógł wygasnąć — poproś o nowy.", + "exportArchive": "Archiwum ZIP", "deleteAccount": "Usuń konto", "deleteAccountDesc": "Trwale usuń swoje konto i wszystkie powiązane dane. Tej akcji nie można cofnąć.", "deleteBtn": "Usuń", diff --git a/messages/zh.json b/messages/zh.json index 170de38a..a1152310 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -238,12 +238,20 @@ "saving": "正在保存...", "dangerZoneTitle": "危险区域", "exportData": "导出我的数据", - "exportDataDesc": "申请获取您的个人数据副本:账户信息、设置及项目成员资格。下载链接将通过邮件发送,有效期 7 天。", + "exportDataDesc": "申请获取您的个人数据副本:账户信息、设置及项目成员资格。可在此处下载,有效期 7 天。", "exportBtn": "申请导出", "exportRequesting": "申请中...", - "exportRequested": "导出已开始 — 您很快会收到包含下载链接的邮件。链接有效期为 7 天。", - "exportPending": "已有导出正在准备中,请等待邮件。", + "exportRequested": "导出已开始 — 稍后即可在此处下载,有效期 7 天。", + "exportPending": "已有导出正在准备中,准备好后会显示在此处。", + "exportThrottled": "您最近已申请过导出,请在一小时后再试。", "exportFailed": "导出申请失败,请重试。", + "exportStatePreparing": "正在准备您的导出,请稍候。", + "exportStateReady": "您的导出可在 {date} 前下载。", + "exportStateExpired": "您上次的导出已过期。请重新申请以再次下载您的数据。", + "exportDownload": "下载", + "exportDownloading": "下载中...", + "exportDownloadFailed": "导出下载失败,可能已过期 — 请重新申请。", + "exportArchive": "ZIP 压缩包", "deleteAccount": "注销账户", "deleteAccountDesc": "永久删除账户及数据。不可恢复。", "deleteBtn": "删除", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a1ef199..b9ede8d0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,7 +151,7 @@ model ProjectAsset { } // One GDPR data-access request. The zip lives in R2 at `key` until `expiresAt` -// (7 days); a PENDING row younger than an hour blocks duplicate requests. +// (7 days); any non-FAILED row younger than an hour blocks a new request. model DataExport { id String @id @default(uuid(7)) status DataExportStatus @default(PENDING) diff --git a/src/app/api/users/export/download/route.ts b/src/app/api/users/export/download/route.ts new file mode 100644 index 00000000..bbec3bd6 --- /dev/null +++ b/src/app/api/users/export/download/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; + +import * as GdprExportService from "@src/server/service/gdpr-export-service"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { validate } from "@src/lib/utils/api-utils"; + +import z from "zod"; + +const QuerySchema = z.object({ + id: z.string(), +}); + +/** + * GET `/users/export/download?id=` + * + * Serves the export zip to the account settings, streamed through the API so + * the bucket stays private and no pre-signed URL ever leaves the server. The + * archive is personal data in bulk and reachable only by the signed-in user who + * requested it — nothing about it is delivered by email. + */ +async function downloadDataExport(req: NextRequest, { searchParams, user }: AuthApiContext) { + const { id } = validate(QuerySchema, searchParams); + + const { bytes, filename } = await GdprExportService.getExportArchive(id, user.id); + + return new NextResponse(bytes as BodyInit, { + headers: { + "Content-Type": "application/zip", + "Content-Length": String(bytes.byteLength), + "Content-Disposition": `attachment; filename="${filename}"`, + // Personal data: no cache may keep a copy of this response. + "Cache-Control": "no-store, private", + }, + }); +} + +export const GET = apiHandler(downloadDataExport); diff --git a/src/app/api/users/export/route.ts b/src/app/api/users/export/route.ts index b303e65e..e7839b72 100644 --- a/src/app/api/users/export/route.ts +++ b/src/app/api/users/export/route.ts @@ -4,13 +4,23 @@ import * as GdprExportService from "@src/server/service/gdpr-export-service"; import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import { Success } from "@src/lib/utils/api-utils"; +/** + * GET `/users/export` + * + * What the account settings render: whether an export is being prepared, ready + * to download, or expired, and when a new one may be requested. + */ +async function getDataExportState(req: NextRequest, { user }: AuthApiContext) { + return Success(await GdprExportService.getDataExportState(user.id)); +} + /** * POST `/users/export` * * GDPR data-access request. Records the request and returns immediately; the - * zip is bundled in the background (`after`) and a signed download link, valid - * 7 days, is emailed to the user. 409 while a previous request is still - * building. + * zip is bundled in the background (`after`) and stays downloadable from the + * settings for 7 days, with a notification email once it is ready. 409 while a + * previous request is still building, 429 within an hour of the last one. */ async function requestDataExport(req: NextRequest, { user }: AuthApiContext) { const exportId = await GdprExportService.beginDataExport(user.id); @@ -18,4 +28,5 @@ async function requestDataExport(req: NextRequest, { user }: AuthApiContext) { return Success({ requested: true }); } +export const GET = apiHandler(getDataExportState); export const POST = apiHandler(requestDataExport); diff --git a/src/lib/adapters/screenplay-adapter.ts b/src/lib/adapters/screenplay-adapter.ts index 1550d6ad..ee0b061d 100644 --- a/src/lib/adapters/screenplay-adapter.ts +++ b/src/lib/adapters/screenplay-adapter.ts @@ -1,7 +1,5 @@ -import FileSaver from "file-saver"; -import { isTauri } from "@tauri-apps/api/core"; -import { isIOS } from "../utils/platform"; import { replaceScreenplay } from "../screenplay/editor"; +import { saveBlob } from "../utils/save-file"; import { Editor } from "@tiptap/react"; import { LayoutData, ProjectData, ProjectMetadata, ProjectState } from "../project/project-state"; import { ProjectRepository } from "../project/project-repository"; @@ -63,78 +61,16 @@ export abstract class ProjectAdapter/`, hands that to - * the picker, and returns where the user put it. - * - * The copy therefore happens when the user confirms — before we ever get the - * path back. Writing after `save()` resolves, the way desktop does, exports - * the empty placeholder and leaves a 0-byte file: the destination URL is - * outside our sandbox, so the later write lands nowhere the user can see. - * - * So stage the real bytes at exactly the path the plugin will use and let - * the picker export those. It only creates the placeholder - * (`if !fileManager.fileExists`) when nothing is there, so a pre-written file - * survives — this hooks that, rather than fighting it. - * - * The staged copy is left behind on purpose. The app declares neither - * `UIFileSharingEnabled` nor `LSSupportsOpeningDocumentsInPlace`, so - * `` is invisible to the user, one file per title at most; and - * deleting it would race the picker's copy for cloud destinations. - */ - private async exportIOS(blob: Blob, options: TExportOptions, target: ExportTarget): Promise { - const { save } = await import("@tauri-apps/plugin-dialog"); - const { writeFile, BaseDirectory } = await import("@tauri-apps/plugin-fs"); - - // The plugin derives its staging path with `PathBuf::file_name()`, which - // would drop everything before a separator in the title and stage under a - // name we never wrote. Keep the two in lockstep. - const fileName = `${options.title.replace(/[\\/:*?"<>|]/g, "-")}.${target.extension}`; - - const buffer = new Uint8Array(await blob.arrayBuffer()); - await writeFile(fileName, buffer, { baseDir: BaseDirectory.Document }); - - // Resolves to the chosen destination, or null if cancelled. Either way the - // export is already done — there is nothing left for us to write. - await save({ - defaultPath: fileName, - filters: [{ name: this.label, extensions: [target.extension] }], - }); - } - - private async exportDesktop(blob: Blob, options: TExportOptions, target: ExportTarget): Promise { - const { save } = await import("@tauri-apps/plugin-dialog"); - const { writeFile } = await import("@tauri-apps/plugin-fs"); - const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); - - const filePath = await save({ - defaultPath: `${options.title}.${target.extension}`, - filters: [{ name: this.label, extensions: [target.extension] }], - }); - - if (!filePath) return; - - const buffer = new Uint8Array(await blob.arrayBuffer()); - await writeFile(filePath, buffer); - await revealItemInDir(filePath); - } - public import( rawContent: ArrayBuffer, editor?: Editor | null, diff --git a/src/lib/mail/mail.ts b/src/lib/mail/mail.ts index 17c74007..8c4e2502 100644 --- a/src/lib/mail/mail.ts +++ b/src/lib/mail/mail.ts @@ -21,10 +21,16 @@ export const sendProjectInviteEmail = async (email: string, projectTitle: string sendFormattedEmail(email, "Project Invitation", "Project Invitation", content, "Join project", link); }; -export const sendDataExportEmail = async (email: string, link: string) => { - const content = `Your personal data export is ready. Click the button below to download the archive containing your account information and project memberships. The link expires in 7 days.`; +/** + * Notification only — deliberately carries no download link. The archive is + * fetched from the account settings by the signed-in user, so this mail is + * worth nothing to anyone who intercepts it, and it lets the account holder + * spot an export they never asked for. + */ +export const sendDataExportEmail = async (email: string) => { + const content = `Your personal data export is ready. Open Scriptio and go to Settings → Account → Profile to download the archive containing your account information and project memberships. You have 7 days to download it. If you did not request this export, someone may have access to your account — change how you sign in and contact us.`; - sendFormattedEmail(email, "Your data export", "Your data export is ready", content, "Download my data", link); + sendFormattedEmail(email, "Your data export", "Your data export is ready", content, "Open Scriptio", BASE_URL); }; export const sendMagicLinkEmail = async (email: string, token: string) => { diff --git a/src/lib/s3.ts b/src/lib/s3.ts index 766811db..49fc37d9 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -20,9 +20,6 @@ const client = new S3Client({ }, }); -/** SigV4 caps presigned-URL validity at 7 days. */ -export const MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 3600; - export const getSignedDownloadUrl = async (name: string, expiresIn = 900): Promise => { const params = { Bucket: env.S3_BUCKET, diff --git a/src/lib/utils/api-utils.ts b/src/lib/utils/api-utils.ts index 51f9ff51..6d994209 100644 --- a/src/lib/utils/api-utils.ts +++ b/src/lib/utils/api-utils.ts @@ -58,6 +58,11 @@ export class ConflictError extends AppError { super(409, message); } } +export class TooManyRequestsError extends AppError { + constructor(message = "Too many requests") { + super(429, message); + } +} export class InternalServerError extends AppError { constructor(message = "Internal server error") { super(500, message); diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts index 47b5ee77..6920e553 100644 --- a/src/lib/utils/hooks.ts +++ b/src/lib/utils/hooks.ts @@ -2,7 +2,7 @@ import useSWR, { useSWRConfig } from "swr"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { CookieUser, UserSettings } from "./types"; +import { CookieUser, DataExportState, UserSettings } from "./types"; import { editUserSettings } from "./requests"; import { readLocalSettings, writeLocalSettings, DEFAULT_LOCAL_SETTINGS } from "./local-settings"; import { ProjectContext } from "@src/context/ProjectContext"; @@ -325,6 +325,20 @@ const useUser = () => { return { user, isLoading, mutate }; }; +/** + * State of the user's GDPR data export, as the account settings render it. + * + * The zip is built in the background straight after the request, so poll while + * it is being prepared — the panel then flips to "ready" on its own instead of + * making the user reload to find out. + */ +const useDataExport = () => { + const { data, isLoading, mutate } = useSWR("/api/users/export", { + refreshInterval: (latest) => (latest?.status === "PENDING" ? 5000 : 0), + }); + return { dataExport: data, isLoading, mutate }; +}; + const useCookieUser = (redirect: boolean = false) => { const { data: user, isLoading, error } = useSWR("/api/users/cookie"); const [localUser, setLocalUser] = useState(undefined); @@ -787,6 +801,7 @@ const useFormatTimestamp = () => { export { useDraggable, useUser, + useDataExport, useCookieUser, useSettings, useIsPro, diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index e8c4dae6..be88e330 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -129,6 +129,10 @@ export const requestDataExport = () => { return request(`/api/users/export`, "POST"); }; +export const downloadDataExport = (id: string) => { + return request(`/api/users/export/download?id=${encodeURIComponent(id)}`, "GET"); +}; + /* Auth */ export const requestMagicLink = (body: RequestMagicLinkBody) => { diff --git a/src/lib/utils/save-file.ts b/src/lib/utils/save-file.ts new file mode 100644 index 00000000..5f3d45e8 --- /dev/null +++ b/src/lib/utils/save-file.ts @@ -0,0 +1,91 @@ +/** + * Hand a blob to the user as a file. + * + * The three environments the app ships in each save differently, and the iOS + * one is genuinely surprising, so every caller goes through here rather than + * rediscovering it: the browser downloads, Tauri desktop opens a save dialog and + * reveals the result, and Tauri iOS has to stage the bytes before it can ask. + */ + +import FileSaver from "file-saver"; +import { isTauri } from "@tauri-apps/api/core"; + +import { isIOS } from "../utils/platform"; + +/** How the native save dialog labels and filters the file being written. */ +export type SaveFileFilter = { + /** Human-readable format name shown in the dialog, e.g. "Final Draft". */ + label: string; + /** Suffix, lower-case and without the dot, e.g. "fdx". */ + extension: string; +}; + +/** + * iOS has no "choose a path, then write to it" dialog. `UIDocumentPicker` in + * `.exportToService` mode only moves an *existing* file to a location the + * user picks, so tauri-plugin-dialog's `save()` fakes the cross-platform + * shape: it creates a placeholder at `/`, hands that to + * the picker, and returns where the user put it. + * + * The copy therefore happens when the user confirms — before we ever get the + * path back. Writing after `save()` resolves, the way desktop does, exports + * the empty placeholder and leaves a 0-byte file: the destination URL is + * outside our sandbox, so the later write lands nowhere the user can see. + * + * So stage the real bytes at exactly the path the plugin will use and let + * the picker export those. It only creates the placeholder + * (`if !fileManager.fileExists`) when nothing is there, so a pre-written file + * survives — this hooks that, rather than fighting it. + * + * The staged copy is left behind on purpose. The app declares neither + * `UIFileSharingEnabled` nor `LSSupportsOpeningDocumentsInPlace`, so + * `` is invisible to the user, one file per name at most; and + * deleting it would race the picker's copy for cloud destinations. + */ +async function saveIOS(blob: Blob, fileName: string, filter: SaveFileFilter): Promise { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeFile, BaseDirectory } = await import("@tauri-apps/plugin-fs"); + + // The plugin derives its staging path with `PathBuf::file_name()`, which + // would drop everything before a separator in the name and stage under a + // name we never wrote. Keep the two in lockstep. + const staged = fileName.replace(/[\\/:*?"<>|]/g, "-"); + + const buffer = new Uint8Array(await blob.arrayBuffer()); + await writeFile(staged, buffer, { baseDir: BaseDirectory.Document }); + + // Resolves to the chosen destination, or null if cancelled. Either way the + // save is already done — there is nothing left for us to write. + await save({ + defaultPath: staged, + filters: [{ name: filter.label, extensions: [filter.extension] }], + }); +} + +async function saveDesktop(blob: Blob, fileName: string, filter: SaveFileFilter): Promise { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeFile } = await import("@tauri-apps/plugin-fs"); + const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + + const filePath = await save({ + defaultPath: fileName, + filters: [{ name: filter.label, extensions: [filter.extension] }], + }); + + if (!filePath) return; + + const buffer = new Uint8Array(await blob.arrayBuffer()); + await writeFile(filePath, buffer); + await revealItemInDir(filePath); +} + +/** Write `blob` to wherever this platform puts user-saved files. */ +export async function saveBlob(blob: Blob, fileName: string, filter: SaveFileFilter): Promise { + if (isTauri() && isIOS()) { + await saveIOS(blob, fileName, filter); + } else if (isTauri()) { + await saveDesktop(blob, fileName, filter); + } else { + FileSaver.saveAs(blob, fileName); + } +} diff --git a/src/lib/utils/types.ts b/src/lib/utils/types.ts index 7422f98e..65ac5f4f 100644 --- a/src/lib/utils/types.ts +++ b/src/lib/utils/types.ts @@ -32,6 +32,22 @@ export type ProjectUpdate = { hasPoster?: boolean; }; +/** + * What the account settings show about the user's GDPR data export. + * + * `NONE` covers "never asked" and "the last attempt failed" alike — both leave + * the user with nothing to download and nothing to wait for. + */ +export type DataExportState = { + /** Id to download, present only while `status` is READY. */ + id: string | null; + status: "NONE" | "PENDING" | "READY" | "EXPIRED"; + /** When the READY archive stops being downloadable (ISO). */ + expiresAt: string | null; + /** When a new export may be requested (ISO), or null if one may be now. */ + canRequestAt: string | null; +}; + /* User Settings */ export interface UserSettings { keybinds: Record; diff --git a/src/server/repository/data-export-repository.ts b/src/server/repository/data-export-repository.ts index 9bf95ac6..009e4650 100644 --- a/src/server/repository/data-export-repository.ts +++ b/src/server/repository/data-export-repository.ts @@ -6,13 +6,54 @@ export class DataExportRepository { return prisma.dataExport.create({ data: { userId } }); } - /** The user's PENDING export created after `since`, if any (duplicate-request guard). */ - findActivePending(userId: string, since: Date) { + findById(id: string) { + return prisma.dataExport.findUnique({ where: { id } }); + } + + /** The user's newest export created after `since` that still counts against + * the cooldown. FAILED rows do not, so a failed run can be retried at once. */ + findLatestSince(userId: string, since: Date) { return prisma.dataExport.findFirst({ - where: { userId, status: DataExportStatus.PENDING, createdAt: { gte: since } }, + where: { + userId, + status: { not: DataExportStatus.FAILED }, + createdAt: { gte: since }, + }, + orderBy: { createdAt: "desc" }, }); } + /** The user's most recent export whatever its outcome (drives the UI state). */ + findLatest(userId: string) { + return prisma.dataExport.findFirst({ where: { userId }, orderBy: { createdAt: "desc" } }); + } + + /** The newest export that can still be downloaded right now. */ + findLatestDownloadable(userId: string, now: Date) { + return prisma.dataExport.findFirst({ + where: { + userId, + status: DataExportStatus.COMPLETED, + key: { not: null }, + expiresAt: { gt: now }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + /** Exports whose download link has lapsed — their zip is dead weight in R2. */ + findExpired(userId: string, before: Date) { + return prisma.dataExport.findMany({ + where: { userId, key: { not: null }, expiresAt: { lt: before } }, + select: { id: true, key: true }, + }); + } + + /** Forget the keys of exports whose zips have just been reclaimed. */ + clearKeys(ids: string[]) { + return prisma.dataExport.updateMany({ where: { id: { in: ids } }, data: { key: null } }); + } + /** Mark PENDING rows older than `before` FAILED — leftovers of a crashed * server that would otherwise block the user's next request forever. */ failStalePending(userId: string, before: Date) { diff --git a/src/server/service/gdpr-export-service.ts b/src/server/service/gdpr-export-service.ts index 632c1321..bdbf3c57 100644 --- a/src/server/service/gdpr-export-service.ts +++ b/src/server/service/gdpr-export-service.ts @@ -2,8 +2,7 @@ * GDPR data-access export. * * Bundles the personal data the database actually links to a user — account - * info/settings and project memberships — into a zip on R2 and mails them a - * signed download link, valid 7 days (the SigV4 maximum): + * info/settings and project memberships — into a zip on R2: * * user.json — account info + settings * memberships.json — every project membership with its role @@ -12,6 +11,13 @@ * to users in the database — it belongs to the project — so there is nothing * per-project to bundle. * + * The archive is downloaded from the account settings, which read + * `getDataExportState` and hit `/users/export/download` with the session — the + * bytes never leave the server behind anything but a logged-in request. The + * email that follows is a notification only: it carries no link to the data, so + * it is worthless to anyone who intercepts it, and it lets the account holder + * notice an export they did not ask for. + * * fflate's async `zip` compresses in a worker thread and the job runs after * the response (`after`), so requests never block the event loop. */ @@ -22,15 +28,21 @@ import * as S3 from "@src/lib/s3"; import * as ProjectService from "@src/server/service/project-service"; import * as UserService from "@src/server/service/user-service"; import { sendDataExportEmail } from "@src/lib/mail/mail"; -import { ConflictError } from "@src/lib/utils/api-utils"; +import { ConflictError, NotFoundError, TooManyRequestsError } from "@src/lib/utils/api-utils"; import { logger } from "@src/lib/utils/logger"; +import { DataExportStatus } from "@src/generated/client/client"; +import type { DataExportState } from "@src/lib/utils/types"; import { DataExportRepository } from "../repository/data-export-repository"; const repository = new DataExportRepository(); -const EXPORT_LINK_TTL_SECONDS = S3.MAX_SIGNED_URL_TTL_SECONDS; // 7 days +/** How long a completed export stays downloadable. */ +const EXPORT_LINK_TTL_MS = 7 * 24 * 60 * 60 * 1000; /** A PENDING row older than this is a crash leftover, not a running job. */ const PENDING_STALE_MS = 60 * 60 * 1000; +/** One export per user per hour: bundling and mailing is not free, and a second + * request minutes after the first only ever produces the same zip again. */ +const EXPORT_COOLDOWN_MS = 60 * 60 * 1000; const zipAsync = (data: Zippable): Promise => new Promise((resolve, reject) => { @@ -39,19 +51,93 @@ const zipAsync = (data: Zippable): Promise => /** * Validate and record a new export request. Throws ConflictError while a - * recent request is still building. Returns the export id for `runDataExport`. + * request is still building and TooManyRequestsError while the cooldown of the + * last one has not lapsed. Returns the export id for `runDataExport`. */ export async function beginDataExport(userId: string): Promise { - const staleBefore = new Date(Date.now() - PENDING_STALE_MS); - await repository.failStalePending(userId, staleBefore); + const now = Date.now(); + await repository.failStalePending(userId, new Date(now - PENDING_STALE_MS)); - const active = await repository.findActivePending(userId, staleBefore); - if (active) throw new ConflictError("A data export is already being prepared"); + const recent = await repository.findLatestSince(userId, new Date(now - EXPORT_COOLDOWN_MS)); + if (recent) { + if (recent.status === DataExportStatus.PENDING) + throw new ConflictError("A data export is already being prepared"); + throw new TooManyRequestsError("A data export was requested less than an hour ago"); + } const row = await repository.createPending(userId); return row.id; } +/** Resolve what the settings panel should show, without mutating anything. */ +export async function getDataExportState(userId: string): Promise { + const now = Date.now(); + const [latest, downloadable] = await Promise.all([ + repository.findLatest(userId), + repository.findLatestDownloadable(userId, new Date(now)), + ]); + + // A PENDING row past the stale cutoff is a crash leftover. `beginDataExport` + // fails it on the next request; a read must not show it as still running. + const isPreparing = + latest?.status === DataExportStatus.PENDING && + latest.createdAt.getTime() > now - PENDING_STALE_MS; + + // Mirrors the guard in `beginDataExport` so the button and the API agree on + // when a request is allowed. Safe because a stale PENDING is necessarily + // older than the cooldown too (PENDING_STALE_MS === EXPORT_COOLDOWN_MS). + const blocking = latest && latest.status !== DataExportStatus.FAILED ? latest : null; + const cooldownEnd = blocking ? blocking.createdAt.getTime() + EXPORT_COOLDOWN_MS : 0; + + const status: DataExportState["status"] = isPreparing + ? "PENDING" + : downloadable + ? "READY" + : latest?.status === DataExportStatus.COMPLETED + ? "EXPIRED" + : "NONE"; + + return { + id: downloadable?.id ?? null, + status, + expiresAt: downloadable?.expiresAt?.toISOString() ?? null, + canRequestAt: cooldownEnd > now ? new Date(cooldownEnd).toISOString() : null, + }; +} + +/** + * Read a completed export on behalf of the signed-in caller. Everything that + * makes the archive unavailable — someone else's export, an id that does not + * exist, a run that never completed, a lapsed link — answers with the same 404, + * so the endpoint never confirms an export id to whoever is holding the link. + */ +export async function getExportArchive( + exportId: string, + userId: string, +): Promise<{ bytes: Uint8Array; filename: string }> { + const record = await repository.findById(exportId); + const unavailable = new NotFoundError("This data export is no longer available"); + + if (!record || record.userId !== userId) throw unavailable; + if (record.status !== DataExportStatus.COMPLETED || !record.key) throw unavailable; + if (record.expiresAt && record.expiresAt.getTime() < Date.now()) throw unavailable; + + const bytes = await S3.getObjectBytes(record.key); + if (!bytes) throw unavailable; + + return { bytes, filename: record.key.split("/").pop() ?? "scriptio-data-export.zip" }; +} + +/** Delete the R2 objects of exports whose download link has already lapsed. */ +async function purgeExpiredExports(userId: string): Promise { + const expired = await repository.findExpired(userId, new Date()); + const keys = expired.map((e) => e.key).filter((key): key is string => !!key); + if (!keys.length) return; + + await S3.destroyMany(keys); + await repository.clearKeys(expired.map((e) => e.id)); +} + /** * Build the zip, upload it and email the link. Runs in the background after * the request already returned — never throws, records FAILED instead. @@ -62,9 +148,9 @@ export async function runDataExport(exportId: string, userId: string): Promise Date: Wed, 19 Aug 2026 17:57:51 +0200 Subject: [PATCH 2/3] fixed poster handling --- .../dashboard/project/ProjectSettings.tsx | 70 +++--- components/projects/ProjectItem.tsx | 8 +- .../api/projects/[projectId]/poster/route.ts | 92 ++++++++ src/app/api/projects/[projectId]/route.ts | 12 +- src/app/api/projects/route.ts | 8 +- src/context/ProjectContext.tsx | 8 + .../indexeddb-storage-provider.ts | 40 +++- .../storage-provider/local-persistence.ts | 20 +- .../migrations/store-migrations.ts | 19 ++ .../storage-provider/storage-provider.ts | 29 +++ src/lib/posters/cloud-poster-sync.ts | 50 ++++ src/lib/posters/poster-store.ts | 214 ++++++++++++++++++ src/lib/posters/use-poster-url.ts | 59 +++++ src/lib/s3.ts | 17 -- src/lib/utils/api-bodies.ts | 2 - src/lib/utils/hooks.ts | 1 - src/lib/utils/misc.ts | 21 +- src/lib/utils/storage-limits.ts | 7 + src/server/repository/project-repository.ts | 37 +-- .../service/project-teardown-service.ts | 6 +- .../migrations/store-migration-runner.test.ts | 1 + src/tests/posters/posters.test.ts | 210 +++++++++++++++++ 22 files changed, 824 insertions(+), 107 deletions(-) create mode 100644 src/app/api/projects/[projectId]/poster/route.ts create mode 100644 src/lib/posters/cloud-poster-sync.ts create mode 100644 src/lib/posters/poster-store.ts create mode 100644 src/lib/posters/use-poster-url.ts create mode 100644 src/tests/posters/posters.test.ts diff --git a/components/dashboard/project/ProjectSettings.tsx b/components/dashboard/project/ProjectSettings.tsx index 4430d4c0..d68a3faa 100644 --- a/components/dashboard/project/ProjectSettings.tsx +++ b/components/dashboard/project/ProjectSettings.tsx @@ -1,11 +1,12 @@ "use client"; -import { cropImageBase64 } from "@src/lib/utils/misc"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { editProject } from "@src/lib/utils/requests"; import { useContext, useEffect, useState } from "react"; import { useProjectMembership, useCachedProjectInfo, useProjectIdFromUrl } from "@src/lib/utils/hooks"; +import { savePosterFromFile } from "@src/lib/posters/poster-store"; +import { usePosterUrl } from "@src/lib/posters/use-poster-url"; import { ProjectContext } from "@src/context/ProjectContext"; import UploadButton from "@components/projects/UploadButton"; import DangerZone from "./DangerZone"; @@ -28,19 +29,27 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const [isDirty, setDirty] = useState(false); const [selectedFile, setSelectedFile] = useState(null); - const [previewUrl, setPreviewUrl] = useState(membership?.project.poster || null); + const [filePreviewUrl, setFilePreviewUrl] = useState(null); const [loading, setLoading] = useState(false); + // The saved poster comes from the local store (works offline and for + // local-only projects); a freshly picked file previews over it until saved. + const savedPosterUrl = usePosterUrl(projectId); + const previewUrl = filePreviewUrl ?? savedPosterUrl; + // Get project data from membership or local info const projectTitle = membership?.project.title || localTitle; const projectDescription = membership?.project.description || localDescription; const projectAuthor = membership?.project.author || localAuthor; useEffect(() => { - if (!selectedFile) return; + if (!selectedFile) { + setFilePreviewUrl(null); + return; + } const objectUrl = URL.createObjectURL(selectedFile); setDirty(true); - setPreviewUrl(objectUrl); + setFilePreviewUrl(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [selectedFile]); @@ -68,19 +77,24 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; console.error("[ProjectSettings] Failed to save local project:", error); } + // The poster is stored locally first and mirrored to the cloud from + // there, so this works the same for local-only, cloud and offline projects. + if (selectedFile) { + try { + await savePosterFromFile(projectId, selectedFile); + setSelectedFile(null); + } catch (error) { + console.error("[ProjectSettings] Failed to save poster:", error); + } + } + if (!isLocalOnly && membership) { // Also save to remote API - const body: { title: string; description: string; author: string; poster?: string } = { + await editProject(membership.project.id, { title: newTitle, description: newDescription, author: newAuthor, - }; - - if (selectedFile) { - body.poster = await cropImageBase64(selectedFile, 600, 900); - } - - await editProject(membership.project.id, body); + }); } // Sync title/author to Yjs metadata (updates title page editor) @@ -140,25 +154,23 @@ const ProjectSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; />
- {/* Poster - only show for remote projects */} - {!isLocalOnly && ( -
- -
-
- {previewUrl ? ( - Preview - ) : ( -
{t("noPoster")}
- )} -
-
-

{t("posterHelp")}

- -
+ {/* Poster */} +
+ +
+
+ {previewUrl ? ( + Preview + ) : ( +
{t("noPoster")}
+ )} +
+
+

{t("posterHelp")}

+
- )} +