From 9da5a4d24932e62901f20ac192420fe722d29102 Mon Sep 17 00:00:00 2001 From: naaiyy Date: Fri, 14 Aug 2026 17:28:53 +0200 Subject: [PATCH 1/4] Fix quick link image previews --- .../openeditor/custom-block-viewer.tsx | 2 +- .../features/openeditor/custom-blocks.test.ts | 46 ++++++++++++++++++- .../web/features/openeditor/custom-blocks.tsx | 23 ++++++++++ .../openeditor/openeditor-page-editor.tsx | 22 ++++----- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/apps/web/features/openeditor/custom-block-viewer.tsx b/apps/web/features/openeditor/custom-block-viewer.tsx index d6c1fdc4..24e7a866 100644 --- a/apps/web/features/openeditor/custom-block-viewer.tsx +++ b/apps/web/features/openeditor/custom-block-viewer.tsx @@ -19,7 +19,7 @@ type NestedRuntimes = { pageRuntime?: OpenEditorPageRuntime; }; export const createBaseBlocksCustomBlockViewerConfiguration = ( - authorizedAssetIds: ReadonlySet, + authorizedAssetIds: Pick, "has">, runtimes: NestedRuntimes = {}, ) => { const DocumentViewer = ({ diff --git a/apps/web/features/openeditor/custom-blocks.test.ts b/apps/web/features/openeditor/custom-blocks.test.ts index 8f93c75c..94fb9962 100644 --- a/apps/web/features/openeditor/custom-blocks.test.ts +++ b/apps/web/features/openeditor/custom-blocks.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { OpenEditorDocument } from "@openeditor/core"; -import { extractBaseBlocksCustomBlockAssetIds } from "./custom-blocks"; +import { + BaseBlocksCustomBlockAssetAuthorization, + extractBaseBlocksCustomBlockAssetIds, +} from "./custom-blocks"; +import { createBaseBlocksCustomBlockHost } from "./custom-block-host"; const quickLinksWithAsset = (assetId: string) => ({ type: "customBlock", @@ -54,4 +58,44 @@ describe("custom-block asset authorization", () => { new Set(["top_asset", "nested_asset"]), ); }); + + test("keeps a newly uploaded asset resolvable before the document saves", async () => { + const document = { + type: "doc", + version: 1, + content: [], + } as OpenEditorDocument; + const authorization = new BaseBlocksCustomBlockAssetAuthorization(document); + const uploaded = authorization.authorize({ + id: "new_image", + kind: "raster" as const, + alt: "Preview", + }); + + authorization.updateDocument(document); + const host = createBaseBlocksCustomBlockHost(authorization); + + expect(uploaded).not.toBeNull(); + expect(await host.assets.resolve(uploaded!.id)).toEqual({ + src: "/api/files/new_image", + alt: "", + }); + }); + + test("stops authorizing a saved asset after the document removes it", () => { + const authorization = new BaseBlocksCustomBlockAssetAuthorization({ + type: "doc", + version: 1, + content: [quickLinksWithAsset("removed_image")], + } as OpenEditorDocument); + + expect(authorization.has("removed_image")).toBe(true); + authorization.updateDocument({ + type: "doc", + version: 1, + content: [], + } as OpenEditorDocument); + + expect(authorization.has("removed_image")).toBe(false); + }); }); diff --git a/apps/web/features/openeditor/custom-blocks.tsx b/apps/web/features/openeditor/custom-blocks.tsx index 064bdbcf..58879efc 100644 --- a/apps/web/features/openeditor/custom-blocks.tsx +++ b/apps/web/features/openeditor/custom-blocks.tsx @@ -72,6 +72,29 @@ export function authorizeBaseBlocksCustomBlockAsset( return asset; } +export class BaseBlocksCustomBlockAssetAuthorization { + private documentAssetIds = new Set(); + private pendingAssetIds = new Set(); + + constructor(document: OpenEditorDocument) { + this.updateDocument(document); + } + + updateDocument(document: OpenEditorDocument) { + this.documentAssetIds = extractBaseBlocksCustomBlockAssetIds(document); + for (const id of this.documentAssetIds) this.pendingAssetIds.delete(id); + } + + authorize(asset: T | null) { + if (asset) this.pendingAssetIds.add(asset.id); + return asset; + } + + has(id: string) { + return this.documentAssetIds.has(id) || this.pendingAssetIds.has(id); + } +} + function DocumentEditorSurface({ value, onChange, diff --git a/apps/web/features/openeditor/openeditor-page-editor.tsx b/apps/web/features/openeditor/openeditor-page-editor.tsx index 889e3e55..e1ca13a7 100644 --- a/apps/web/features/openeditor/openeditor-page-editor.tsx +++ b/apps/web/features/openeditor/openeditor-page-editor.tsx @@ -39,9 +39,8 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { useBaseBlocksAttachmentRuntime } from "./attachment-runtime"; import { - authorizeBaseBlocksCustomBlockAsset, + BaseBlocksCustomBlockAssetAuthorization, createBaseBlocksCustomBlockEditorConfiguration, - extractBaseBlocksCustomBlockAssetIds, } from "./custom-blocks"; import { createBaseBlocksCustomBlockViewerConfiguration } from "./custom-block-viewer"; import { useBaseBlocksImageRuntime } from "./image-runtime"; @@ -368,20 +367,15 @@ function useBaseBlocksCustomBlockConfigurations( pageRuntime: OpenEditorPageRuntime; }, ) { - const authorizedAssetIdsRef = useRef>( - extractBaseBlocksCustomBlockAssetIds(document), - ); - authorizedAssetIdsRef.current = - extractBaseBlocksCustomBlockAssetIds(document); - const authorizedAssetIds = useRef({ - has: (id: string) => authorizedAssetIdsRef.current.has(id), - }).current; + const assetAuthorization = useRef( + new BaseBlocksCustomBlockAssetAuthorization(document), + ).current; + assetAuthorization.updateDocument(document); const pickAsset = async () => { const input = await imageRuntime.selectImage?.(); if (!input || !imageRuntime.uploadImage) return null; const uploaded = await imageRuntime.uploadImage(input); - return authorizeBaseBlocksCustomBlockAsset( - authorizedAssetIdsRef.current, + return assetAuthorization.authorize( uploaded.imageId ? { id: uploaded.imageId, kind: "raster" as const, alt: uploaded.alt } : null, @@ -389,12 +383,12 @@ function useBaseBlocksCustomBlockConfigurations( }; return { editor: createBaseBlocksCustomBlockEditorConfiguration( - authorizedAssetIds, + assetAuthorization, pickAsset, runtimes, ), viewer: createBaseBlocksCustomBlockViewerConfiguration( - authorizedAssetIdsRef.current, + assetAuthorization, runtimes, ), }; From c6bf0f94286b34cb1db9d3021cb8f2c73a42add2 Mon Sep 17 00:00:00 2001 From: naaiyy Date: Fri, 14 Aug 2026 18:12:03 +0200 Subject: [PATCH 2/4] Manage site asset lifecycle --- .../features/dashboard/sites/site-card.tsx | 9 +- .../sites/site-management-dialogs.tsx | 1 - .../features/dashboard/use-site-navigation.ts | 2 +- .../editor/settings/favicon-settings.tsx | 29 +-- .../editor/settings/site-brand-settings.tsx | 34 ++- .../openeditor/custom-block-host.test.ts | 14 ++ .../features/openeditor/custom-block-host.tsx | 2 + .../features/openeditor/custom-blocks.test.ts | 17 ++ .../web/features/openeditor/custom-blocks.tsx | 12 +- .../openeditor/openeditor-page-editor.tsx | 6 + .../published-sites/favicon-metadata.ts | 2 +- packages/backend/convex/_generated/api.d.ts | 8 + packages/backend/convex/crons.ts | 7 + packages/backend/convex/draftRestore.ts | 24 ++- .../backend/convex/fileExtractionAction.ts | 41 +--- packages/backend/convex/files.ts | 7 +- packages/backend/convex/model/draftChanges.ts | 5 + .../backend/convex/model/pageDocuments.ts | 13 +- .../convex/model/releaseChangeDetails.ts | 1 + .../backend/convex/model/siteAssets.test.ts | 91 ++++++++ packages/backend/convex/model/siteAssets.ts | 195 ++++++++++++++++++ packages/backend/convex/published.ts | 7 +- packages/backend/convex/releasePublication.ts | 7 + packages/backend/convex/releases.ts | 1 + packages/backend/convex/schema.ts | 17 +- packages/backend/convex/siteAssetLifecycle.ts | 145 +++++++++++++ packages/backend/convex/siteAssetPurge.ts | 37 ++++ packages/backend/convex/sites.ts | 85 ++++---- packages/backend/convex/storage.ts | 41 ++++ packages/backend/convex/validators/sites.ts | 1 - .../custom-blocks/src/quick-links-editor.tsx | 61 +++++- 31 files changed, 781 insertions(+), 141 deletions(-) create mode 100644 packages/backend/convex/model/siteAssets.test.ts create mode 100644 packages/backend/convex/model/siteAssets.ts create mode 100644 packages/backend/convex/siteAssetLifecycle.ts create mode 100644 packages/backend/convex/siteAssetPurge.ts create mode 100644 packages/backend/convex/storage.ts diff --git a/apps/web/features/dashboard/sites/site-card.tsx b/apps/web/features/dashboard/sites/site-card.tsx index 92171493..1b597645 100644 --- a/apps/web/features/dashboard/sites/site-card.tsx +++ b/apps/web/features/dashboard/sites/site-card.tsx @@ -41,7 +41,7 @@ interface SiteCardProps { _id: string; name: string; slug: string; - logoUrl?: string; + logoFileId?: string; liveReleaseId?: string; team?: { _id: string; @@ -64,6 +64,9 @@ export function SiteCard({ canManageSites, site, teamSlug }: SiteCardProps) { const publishedSiteHref = getSiteOpenUrl(effectiveTeamSlug, site.slug); const isPublished = Boolean(site.liveReleaseId); const statusLabel = isPublished ? t("sites.published") : t("sites.draft"); + const logoUrl = site.logoFileId + ? `/api/files/${encodeURIComponent(site.logoFileId)}` + : undefined; const handleDelete = async () => { setIsDeleting(true); @@ -88,9 +91,9 @@ export function SiteCard({ canManageSites, site, teamSlug }: SiteCardProps) { />
- {site.logoUrl ? ( + {logoUrl ? ( Promise; + faviconFileId?: Id<"files">; + onChange: (faviconFileId?: Id<"files">) => Promise; siteId: Id<"sites">; }) { const { uploadImage, uploadState } = useImageUpload(); + const [isSaving, setIsSaving] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const upload = async (file?: File) => { @@ -25,16 +26,20 @@ export function FaviconSettings({ return; } - const result = await uploadImage(file, siteId).catch(() => null); - if (!result) { + if (isSaving) return; + setIsSaving(true); + try { + const result = await uploadImage(file, siteId); + if (!result) throw new Error("Upload failed"); + await onChange(result.fileId); + toast.success("Favicon updated"); + } catch { toast.error( - uploadState.error ?? "Unable to upload the favicon. Try again.", + uploadState.error ?? "Unable to save the favicon. Try again.", ); - return; + } finally { + setIsSaving(false); } - - await onChange(result.url); - toast.success("Favicon updated"); }; const remove = async () => { @@ -54,11 +59,11 @@ export function FaviconSettings({ void upload(file)} onRemove={() => void remove()} progress={uploadState.progress?.percentage} - src={favicon} + src={faviconFileId ? `/api/files/${faviconFileId}` : undefined} /> ); } diff --git a/apps/web/features/editor/settings/site-brand-settings.tsx b/apps/web/features/editor/settings/site-brand-settings.tsx index ec374d10..abd26655 100644 --- a/apps/web/features/editor/settings/site-brand-settings.tsx +++ b/apps/web/features/editor/settings/site-brand-settings.tsx @@ -14,16 +14,13 @@ import { FaviconSettings } from "./favicon-settings"; import { ImageAssetDropZone } from "./image-asset-dropzone"; import { SiteSettingsSectionTitle } from "./site-settings-section-title"; -export function SiteBrandSettings({ - site, -}: { - site: Doc<"sites"> & { logoUrl?: string }; -}) { +export function SiteBrandSettings({ site }: { site: Doc<"sites"> }) { const siteId = site._id; const updateSite = useMutation(api.sites.update); const { uploadImage, uploadState } = useImageUpload(); const [name, setName] = useState(site.name); const [isSavingName, setIsSavingName] = useState(false); + const [isSavingLogo, setIsSavingLogo] = useState(false); const [isRemovingLogo, setIsRemovingLogo] = useState(false); useEffect(() => setName(site.name), [site.name]); @@ -53,16 +50,17 @@ export function SiteBrandSettings({ toast.error("Select an image smaller than 5 MB."); return; } - const result = await uploadImage(file, siteId); - if (!result) { - toast.error(uploadState.error ?? "Unable to upload the logo. Try again."); - return; - } + if (isSavingLogo) return; + setIsSavingLogo(true); try { + const result = await uploadImage(file, siteId); + if (!result) throw new Error("Upload failed"); await updateSite({ siteId, logoFileId: result.fileId }); toast.success("Logo uploaded"); } catch { - toast.error("Unable to save the logo. Try again."); + toast.error(uploadState.error ?? "Unable to save the logo. Try again."); + } finally { + setIsSavingLogo(false); } }; @@ -90,7 +88,7 @@ export function SiteBrandSettings({ size="compact" > {isSavingName ? : null} - Save changes + Save name
@@ -113,22 +111,22 @@ export function SiteBrandSettings({ void uploadLogo(file)} onRemove={() => void removeLogo()} progress={uploadState.progress?.percentage} - src={site.logoUrl} + src={site.logoFileId ? `/api/files/${site.logoFileId}` : undefined} /> { + onChange={async (faviconFileId) => { await updateSite( - favicon - ? { siteId, settings: { favicon } } + faviconFileId + ? { siteId, faviconFileId } : { siteId, clearFavicon: true }, ); }} diff --git a/apps/web/features/openeditor/custom-block-host.test.ts b/apps/web/features/openeditor/custom-block-host.test.ts index 4d6a12ea..176a4eb2 100644 --- a/apps/web/features/openeditor/custom-block-host.test.ts +++ b/apps/web/features/openeditor/custom-block-host.test.ts @@ -17,3 +17,17 @@ describe("custom block URL resolution", () => { expect(host.resolveUrl("//other.example/file", "asset")).toBeNull(); }); }); + +test("custom block host forwards pending asset disposal", async () => { + const discarded: string[] = []; + const host = createBaseBlocksCustomBlockHost( + new Set(), + undefined, + async (id) => { + discarded.push(id); + }, + ); + + await host.assets.discard?.("pending-asset"); + expect(discarded).toEqual(["pending-asset"]); +}); diff --git a/apps/web/features/openeditor/custom-block-host.tsx b/apps/web/features/openeditor/custom-block-host.tsx index 9f306a96..f6f1ce40 100644 --- a/apps/web/features/openeditor/custom-block-host.tsx +++ b/apps/web/features/openeditor/custom-block-host.tsx @@ -17,6 +17,7 @@ const safeUrl = (value: string, _context: "navigation" | "asset") => { export const createBaseBlocksCustomBlockHost = ( authorizedAssetIds: Pick, "has">, pickAsset?: () => Promise<{ id: string; kind: "raster"; alt: string } | null>, + discardAsset?: (id: string) => Promise, ) => ({ resolveUrl: safeUrl, links: { @@ -27,6 +28,7 @@ export const createBaseBlocksCustomBlockHost = ( }, assets: { pick: pickAsset, + discard: discardAsset, resolve: async (id: string) => authorizedAssetIds.has(id) && /^[A-Za-z0-9_-]+$/.test(id) ? { src: `/api/files/${encodeURIComponent(id)}`, alt: "" } diff --git a/apps/web/features/openeditor/custom-blocks.test.ts b/apps/web/features/openeditor/custom-blocks.test.ts index 94fb9962..c44e10c8 100644 --- a/apps/web/features/openeditor/custom-blocks.test.ts +++ b/apps/web/features/openeditor/custom-blocks.test.ts @@ -98,4 +98,21 @@ describe("custom-block asset authorization", () => { expect(authorization.has("removed_image")).toBe(false); }); + + test("discards only assets that have not entered the document", () => { + const authorization = new BaseBlocksCustomBlockAssetAuthorization({ + type: "doc", + version: 1, + content: [quickLinksWithAsset("saved_image")], + } as OpenEditorDocument); + authorization.authorize({ + id: "pending_image", + kind: "raster" as const, + alt: "", + }); + + expect(authorization.discard("saved_image")).toBe(false); + expect(authorization.discard("pending_image")).toBe(true); + expect(authorization.has("pending_image")).toBe(false); + }); }); diff --git a/apps/web/features/openeditor/custom-blocks.tsx b/apps/web/features/openeditor/custom-blocks.tsx index 58879efc..86e906ad 100644 --- a/apps/web/features/openeditor/custom-blocks.tsx +++ b/apps/web/features/openeditor/custom-blocks.tsx @@ -85,6 +85,10 @@ export class BaseBlocksCustomBlockAssetAuthorization { for (const id of this.documentAssetIds) this.pendingAssetIds.delete(id); } + discard(id: string) { + return this.pendingAssetIds.delete(id); + } + authorize(asset: T | null) { if (asset) this.pendingAssetIds.add(asset.id); return asset; @@ -138,6 +142,7 @@ export const createBaseBlocksCustomBlockEditorConfiguration = ( authorizedAssetIds: Pick, "has">, pickAsset?: () => Promise<{ id: string; kind: "raster"; alt: string } | null>, runtimes: BaseBlocksNestedRuntimes = {}, + discardAsset?: (id: string) => Promise, ) => { const DocumentEditor = ( props: Omit< @@ -151,6 +156,7 @@ export const createBaseBlocksCustomBlockEditorConfiguration = ( authorizedAssetIds, pickAsset, runtimes, + discardAsset, )} runtimes={runtimes} /> @@ -161,7 +167,11 @@ export const createBaseBlocksCustomBlockEditorConfiguration = ( icons: customBlockSlashMenuIcons, blockMenuExtensions: [baseBlocksCustomBlockMenuExtension], host: { - ...createBaseBlocksCustomBlockHost(authorizedAssetIds, pickAsset), + ...createBaseBlocksCustomBlockHost( + authorizedAssetIds, + pickAsset, + discardAsset, + ), fields: { document: DocumentEditor }, }, }; diff --git a/apps/web/features/openeditor/openeditor-page-editor.tsx b/apps/web/features/openeditor/openeditor-page-editor.tsx index e1ca13a7..4c201da5 100644 --- a/apps/web/features/openeditor/openeditor-page-editor.tsx +++ b/apps/web/features/openeditor/openeditor-page-editor.tsx @@ -367,6 +367,7 @@ function useBaseBlocksCustomBlockConfigurations( pageRuntime: OpenEditorPageRuntime; }, ) { + const discardSiteAsset = useMutation(api.siteAssetLifecycle.discard); const assetAuthorization = useRef( new BaseBlocksCustomBlockAssetAuthorization(document), ).current; @@ -381,11 +382,16 @@ function useBaseBlocksCustomBlockConfigurations( : null, ); }; + const discardAsset = async (id: string) => { + if (!assetAuthorization.discard(id)) return; + await discardSiteAsset({ fileId: id }).catch(() => undefined); + }; return { editor: createBaseBlocksCustomBlockEditorConfiguration( assetAuthorization, pickAsset, runtimes, + discardAsset, ), viewer: createBaseBlocksCustomBlockViewerConfiguration( assetAuthorization, diff --git a/apps/web/features/published-sites/favicon-metadata.ts b/apps/web/features/published-sites/favicon-metadata.ts index 429c43f5..ddd70b26 100644 --- a/apps/web/features/published-sites/favicon-metadata.ts +++ b/apps/web/features/published-sites/favicon-metadata.ts @@ -29,7 +29,7 @@ export function buildPublicSiteMetadata( const description = truncateDescription( result.descriptionText || `${result.title} on ${result.site.name}`, ); - const favicon = result?.site.settings.favicon; + const favicon = result.site.faviconUrl; return { title: { absolute: title }, diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index a503b10d..a7a16971 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -54,6 +54,7 @@ import type * as model_releaseChanges from "../model/releaseChanges.js"; import type * as model_releaseDiff from "../model/releaseDiff.js"; import type * as model_releaseOperations from "../model/releaseOperations.js"; import type * as model_releaseState from "../model/releaseState.js"; +import type * as model_siteAssets from "../model/siteAssets.js"; import type * as model_siteDeletion from "../model/siteDeletion.js"; import type * as model_storageTelemetry from "../model/storageTelemetry.js"; import type * as model_workspaceFoundation from "../model/workspaceFoundation.js"; @@ -72,9 +73,12 @@ import type * as schema_storageTelemetry from "../schema/storageTelemetry.js"; import type * as schema_workspaces from "../schema/workspaces.js"; import type * as search from "../search.js"; import type * as sharing from "../sharing.js"; +import type * as siteAssetLifecycle from "../siteAssetLifecycle.js"; +import type * as siteAssetPurge from "../siteAssetPurge.js"; import type * as siteAssistantRuns from "../siteAssistantRuns.js"; import type * as siteDomains from "../siteDomains.js"; import type * as sites from "../sites.js"; +import type * as storage from "../storage.js"; import type * as storageTelemetry from "../storageTelemetry.js"; import type * as validators_ai from "../validators/ai.js"; import type * as validators_integrations from "../validators/integrations.js"; @@ -138,6 +142,7 @@ declare const fullApi: ApiFromModules<{ "model/releaseDiff": typeof model_releaseDiff; "model/releaseOperations": typeof model_releaseOperations; "model/releaseState": typeof model_releaseState; + "model/siteAssets": typeof model_siteAssets; "model/siteDeletion": typeof model_siteDeletion; "model/storageTelemetry": typeof model_storageTelemetry; "model/workspaceFoundation": typeof model_workspaceFoundation; @@ -156,9 +161,12 @@ declare const fullApi: ApiFromModules<{ "schema/workspaces": typeof schema_workspaces; search: typeof search; sharing: typeof sharing; + siteAssetLifecycle: typeof siteAssetLifecycle; + siteAssetPurge: typeof siteAssetPurge; siteAssistantRuns: typeof siteAssistantRuns; siteDomains: typeof siteDomains; sites: typeof sites; + storage: typeof storage; storageTelemetry: typeof storageTelemetry; "validators/ai": typeof validators_ai; "validators/integrations": typeof validators_integrations; diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index e3b80c52..7a89b366 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -22,4 +22,11 @@ crons.interval( { limit: 25 }, ); +crons.interval( + "purge abandoned site assets", + { hours: 1 }, + internal.siteAssetPurge.purge, + {}, +); + export default crons; diff --git a/packages/backend/convex/draftRestore.ts b/packages/backend/convex/draftRestore.ts index d3e5dbec..7532ea0b 100644 --- a/packages/backend/convex/draftRestore.ts +++ b/packages/backend/convex/draftRestore.ts @@ -208,6 +208,17 @@ async function validateFiles( .unique(); if (!logo) throw new Error("Historical logo is missing"); } + if (release.faviconFileId) { + const favicon = await ctx.db + .query("releaseFiles") + .withIndex("by_release_file", (q) => + q + .eq("releaseId", restore.releaseId) + .eq("fileId", release.faviconFileId!), + ) + .unique(); + if (!favicon) throw new Error("Historical favicon is missing"); + } return continuePage(page); } @@ -432,6 +443,15 @@ async function restoreFiles( folderId: snapshot.folderId, order: snapshot.order, deletedAt: undefined, + ...(snapshot.kind === "siteAsset" + ? { + assetState: "attached" as const, + assetAttachedAt: previous?.assetAttachedAt ?? Date.now(), + assetExpiresAt: undefined, + assetPurgeAfter: undefined, + assetPurgeError: undefined, + } + : {}), }); const current = await ctx.db.get(snapshot.fileId); if (current) await reconcileRestoredFile(ctx, current); @@ -487,9 +507,7 @@ async function activate(ctx: MutationCtx, restore: Doc<"draftRestores">) { await ctx.db.patch(site._id, { name: release.name, logoFileId: release.logoFileId, - logoUrl: release.logoFileId - ? `/api/files/${release.logoFileId}` - : undefined, + faviconFileId: release.faviconFileId, defaultPageId: release.defaultPageId, settings: release.settings, draftRevision: resultDraftRevision, diff --git a/packages/backend/convex/fileExtractionAction.ts b/packages/backend/convex/fileExtractionAction.ts index 370afcf0..8ebccec0 100644 --- a/packages/backend/convex/fileExtractionAction.ts +++ b/packages/backend/convex/fileExtractionAction.ts @@ -5,8 +5,6 @@ import { iterableSource, } from "@baseblocks/anydoc-convex/node"; import { v } from "convex/values"; -import { Files } from "files-sdk"; -import { s3 } from "files-sdk/s3"; import { internal } from "./_generated/api"; import { internalAction, type ActionCtx } from "./_generated/server"; import type { FileIngestionJob, FileIngestionResult } from "./fileExtraction"; @@ -14,42 +12,7 @@ import { FILE_EXTRACTION_LIMITS, validateStoredSourceMetadata, } from "./model/fileExtraction"; - -function requiredEnv(name: string): string { - const value = globalThis.process.env[name]?.trim(); - if (!value) throw new Error(`Missing ${name}`); - return value; -} - -function forcePathStyle(): boolean { - const value = - globalThis.process.env.FILES_FORCE_PATH_STYLE?.trim().toLowerCase(); - if (!value || value === "true") return true; - if (value === "false") return false; - throw new Error("FILES_FORCE_PATH_STYLE must be true or false"); -} - -let files: Files | undefined; - -function getFiles(): Files { - if (files) return files; - const adapter = globalThis.process.env.FILES_ADAPTER?.trim() || "s3"; - if (adapter !== "s3") - throw new Error(`Unsupported FILES_ADAPTER "${adapter}"`); - files = new Files({ - adapter: s3({ - bucket: requiredEnv("FILES_BUCKET"), - endpoint: requiredEnv("FILES_ENDPOINT"), - region: requiredEnv("FILES_REGION"), - forcePathStyle: forcePathStyle(), - credentials: { - accessKeyId: requiredEnv("FILES_ACCESS_KEY_ID"), - secretAccessKey: requiredEnv("FILES_SECRET_ACCESS_KEY"), - }, - }), - }); - return files; -} +import { getStorage } from "./storage"; const jobArgs = { entityId: v.string(), @@ -93,7 +56,7 @@ const ingestionHandler = createConvexIngestionHandler< retryable: false, }); } - const storage = getFiles(); + const storage = getStorage(); const metadata = await storage.head(source.objectKey, { retries: FILE_EXTRACTION_LIMITS.storageRetries, signal: attempt.signal, diff --git a/packages/backend/convex/files.ts b/packages/backend/convex/files.ts index d067904e..b15bd220 100644 --- a/packages/backend/convex/files.ts +++ b/packages/backend/convex/files.ts @@ -23,6 +23,7 @@ import { upsertDraftFileSearch, } from "./search"; import { recordStorageUsageEvent } from "./model/storageTelemetry"; +import { PENDING_SITE_ASSET_TTL_MS } from "./model/siteAssets"; async function isFileReferencedByAccessiblePage( ctx: Parameters[0], @@ -479,6 +480,9 @@ export const createSiteAsset = mutation({ order: 0, uploadedBy: auth.userId, createdAt: Date.now(), + assetState: "pending", + assetExpiresAt: Date.now() + PENDING_SITE_ASSET_TTL_MS, + assetPurgeAfter: Date.now() + PENDING_SITE_ASSET_TTL_MS, }); await recordStorageUsageEvent(ctx, { organizationId: site.organizationId, @@ -489,9 +493,6 @@ export const createSiteAsset = mutation({ bytes: args.size, idempotencyKey: `file:upload:${fileId}`, }); - await touchSiteDraft(ctx, args.siteId, Date.now(), [ - { entityType: "file", entityId: fileId }, - ]); return { fileId, url: buildFileUrl(fileId) }; }, }); diff --git a/packages/backend/convex/model/draftChanges.ts b/packages/backend/convex/model/draftChanges.ts index b5565b19..bfe65348 100644 --- a/packages/backend/convex/model/draftChanges.ts +++ b/packages/backend/convex/model/draftChanges.ts @@ -46,6 +46,11 @@ async function resolveChange( current: site.logoFileId, released: release?.logoFileId, }, + { + detail: "Favicon changed", + current: site.faviconFileId, + released: release?.faviconFileId, + }, { detail: "Default page changed", current: site.defaultPageId, diff --git a/packages/backend/convex/model/pageDocuments.ts b/packages/backend/convex/model/pageDocuments.ts index 362eb8b8..0104310e 100644 --- a/packages/backend/convex/model/pageDocuments.ts +++ b/packages/backend/convex/model/pageDocuments.ts @@ -8,6 +8,7 @@ import { type OpenEditorDocument, } from "../pageContentFormat"; import { getOrCreateContentObject } from "./contentObjects"; +import { synchronizeDraftPageSiteAssets } from "./siteAssets"; type DbCtx = Pick< GenericQueryCtx | GenericMutationCtx, @@ -72,7 +73,10 @@ export async function writePageContent( if (existing?.contentHash === contentHash) { return { contentHash, revisionId: existing.revisionId, changed: false }; } - const { revisionId } = await getOrCreateContentObject(ctx, { + const previousRevision = existing + ? await ctx.db.get(existing.revisionId) + : null; + const { revisionId, fileIds } = await getOrCreateContentObject(ctx, { siteId: page.siteId, content: serialized, contentHash, @@ -97,5 +101,12 @@ export async function writePageContent( updatedAt, }); } + await synchronizeDraftPageSiteAssets( + ctx, + page.siteId, + previousRevision?.fileIds ?? [], + fileIds, + updatedAt, + ); return { contentHash, revisionId, changed: true }; } diff --git a/packages/backend/convex/model/releaseChangeDetails.ts b/packages/backend/convex/model/releaseChangeDetails.ts index 53e1c9ca..9d9b32cc 100644 --- a/packages/backend/convex/model/releaseChangeDetails.ts +++ b/packages/backend/convex/model/releaseChangeDetails.ts @@ -38,6 +38,7 @@ export async function buildReleaseChangeDetail( fields: compact([ changedField("Site name", base?.name, site.name), changedField("Logo", base?.logoFileId, site.logoFileId), + changedField("Favicon", base?.faviconFileId, site.faviconFileId), changedField("Default page", base?.defaultPageId, site.defaultPageId), changedField("Settings", base?.settings, site.settings), ]), diff --git a/packages/backend/convex/model/siteAssets.test.ts b/packages/backend/convex/model/siteAssets.test.ts new file mode 100644 index 00000000..dd303a3f --- /dev/null +++ b/packages/backend/convex/model/siteAssets.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { claimSiteAssetForPurge, reconcileSiteAsset } from "./siteAssets"; + +function lifecycleContext({ + referenced = false, + state = "pending", + purgeAfter = 100, +}: { + referenced?: boolean; + state?: "pending" | "attached" | "retired"; + purgeAfter?: number; +} = {}) { + const patches: Array> = []; + const file = { + _id: "file-1", + siteId: "site-1", + kind: "siteAsset", + size: 10, + assetState: state, + assetPurgeAfter: purgeAfter, + createdAt: 1, + }; + const site = { + _id: "site-1", + organizationId: "organization-1", + logoFileId: referenced ? file._id : undefined, + }; + const ctx = { + db: { + get: async (id: string) => + id === file._id ? { ...file, ...Object.assign({}, ...patches) } : site, + patch: async (_id: string, value: Record) => { + patches.push(value); + }, + insert: async () => "event-1", + query: (table: string) => ({ + withIndex: () => ({ + collect: async () => [], + first: async () => null, + unique: async () => + table === "workspaceStorageUsage" + ? { + activeFileBytes: 10n, + retainedFileBytes: 0n, + contentPayloadBytes: 0n, + logicalRevisionBytes: 0n, + activeFileCount: 1, + retainedFileCount: 0, + contentPayloadCount: 0, + } + : null, + }), + }), + }, + }; + return { ctx: ctx as never, patches }; +} + +describe("site asset lifecycle", () => { + test("keeps an unreferenced upload pending during its draft window", async () => { + const { ctx, patches } = lifecycleContext(); + await reconcileSiteAsset(ctx, "file-1" as never, { now: 50 }); + expect(patches).toEqual([]); + }); + + test("retires an abandoned pending upload immediately", async () => { + const { ctx, patches } = lifecycleContext(); + await reconcileSiteAsset(ctx, "file-1" as never, { + now: 50, + abandonPending: true, + }); + expect(patches.at(-1)).toMatchObject({ + assetState: "retired", + assetPurgeAfter: 50, + deletedAt: 50, + }); + }); + + test("rechecks references before a physical purge claim", async () => { + const { ctx, patches } = lifecycleContext({ + referenced: true, + state: "retired", + }); + const claimed = await claimSiteAssetForPurge(ctx, "file-1" as never, 200); + expect(claimed).toBeNull(); + expect(patches.at(-1)).toMatchObject({ + assetState: "attached", + assetPurgeAfter: undefined, + }); + }); +}); diff --git a/packages/backend/convex/model/siteAssets.ts b/packages/backend/convex/model/siteAssets.ts new file mode 100644 index 00000000..b6194630 --- /dev/null +++ b/packages/backend/convex/model/siteAssets.ts @@ -0,0 +1,195 @@ +import type { GenericMutationCtx, GenericQueryCtx } from "convex/server"; +import type { DataModel, Doc, Id } from "../_generated/dataModel"; +import { recordStorageUsageEvent } from "./storageTelemetry"; + +type ReadCtx = Pick, "db">; +type WriteCtx = Pick, "db">; + +export const PENDING_SITE_ASSET_TTL_MS = 48 * 60 * 60 * 1000; +export const RETIRED_SITE_ASSET_GRACE_MS = 60 * 60 * 1000; + +function isSiteAsset(file: Doc<"files"> | null): file is Doc<"files"> { + return file?.kind === "siteAsset"; +} + +async function isReferencedByDraftPage( + ctx: ReadCtx, + file: Doc<"files">, +): Promise { + const documents = await ctx.db + .query("pageDocuments") + .withIndex("by_site", (query) => query.eq("siteId", file.siteId)) + .collect(); + for (const document of documents) { + const page = await ctx.db.get(document.pageId); + if (!page || page.deletedAt !== undefined) continue; + const revision = await ctx.db.get(document.revisionId); + if (revision?.fileIds.includes(file._id)) return true; + } + return false; +} + +export async function isSiteAssetReferencedByDraft( + ctx: ReadCtx, + file: Doc<"files">, +): Promise { + if (!isSiteAsset(file)) return false; + const site = await ctx.db.get(file.siteId); + if (!site) return false; + if (site.logoFileId === file._id || site.faviconFileId === file._id) { + return true; + } + return isReferencedByDraftPage(ctx, file); +} + +export async function isSiteAssetReferenced( + ctx: ReadCtx, + file: Doc<"files">, +): Promise { + if (await isSiteAssetReferencedByDraft(ctx, file)) return true; + return Boolean( + await ctx.db + .query("releaseFiles") + .withIndex("by_file", (query) => query.eq("fileId", file._id)) + .first(), + ); +} + +export async function attachSiteAsset( + ctx: WriteCtx, + siteId: Id<"sites">, + fileId: Id<"files">, + now = Date.now(), +) { + const file = await ctx.db.get(fileId); + if ( + !isSiteAsset(file) || + file.siteId !== siteId || + file.assetState === "deleting" + ) { + throw new Error("Invalid site asset"); + } + if (file.deletedAt !== undefined) { + const site = await ctx.db.get(siteId); + if (!site) throw new Error("Site not found"); + await recordStorageUsageEvent(ctx, { + organizationId: site.organizationId, + siteId, + fileId, + kind: "restore", + bytes: file.size, + idempotencyKey: `file:restore:${fileId}:${now}`, + now, + }); + } + await ctx.db.patch(fileId, { + assetState: "attached", + assetAttachedAt: file.assetAttachedAt ?? now, + assetExpiresAt: undefined, + assetPurgeAfter: undefined, + assetPurgeError: undefined, + deletedAt: undefined, + }); + return file; +} + +async function retireSiteAsset( + ctx: WriteCtx, + file: Doc<"files">, + now: number, + purgeAfter: number, +) { + if (file.assetState === "retired" && file.assetPurgeAfter !== undefined) { + return; + } + const site = await ctx.db.get(file.siteId); + if (site && file.deletedAt === undefined) { + await recordStorageUsageEvent(ctx, { + organizationId: site.organizationId, + siteId: site._id, + fileId: file._id, + kind: "softDelete", + bytes: file.size, + idempotencyKey: `file:delete:${file._id}:${now}`, + now, + }); + } + await ctx.db.patch(file._id, { + assetState: "retired", + assetExpiresAt: undefined, + assetPurgeAfter: purgeAfter, + assetPurgeError: undefined, + deletedAt: file.deletedAt ?? now, + }); +} + +export async function reconcileSiteAsset( + ctx: WriteCtx, + fileId: Id<"files">, + options: { now?: number; abandonPending?: boolean } = {}, +) { + const file = await ctx.db.get(fileId); + if (!isSiteAsset(file) || file.assetState === "deleting") return; + const now = options.now ?? Date.now(); + if (await isSiteAssetReferenced(ctx, file)) { + await attachSiteAsset(ctx, file.siteId, file._id, now); + return; + } + if (file.assetState === "pending" && !options.abandonPending) return; + await retireSiteAsset( + ctx, + file, + now, + options.abandonPending ? now : now + RETIRED_SITE_ASSET_GRACE_MS, + ); +} + +export async function synchronizeDraftPageSiteAssets( + ctx: WriteCtx, + siteId: Id<"sites">, + previousFileIds: readonly Id<"files">[], + nextFileIds: readonly Id<"files">[], + now = Date.now(), +) { + const previous = new Set(previousFileIds); + const next = new Set(nextFileIds); + for (const fileId of next) { + const file = await ctx.db.get(fileId); + if (isSiteAsset(file)) await attachSiteAsset(ctx, siteId, fileId, now); + } + for (const fileId of previous) { + if (next.has(fileId)) continue; + const file = await ctx.db.get(fileId); + if (isSiteAsset(file)) await reconcileSiteAsset(ctx, fileId, { now }); + } +} + +export async function claimSiteAssetForPurge( + ctx: WriteCtx, + fileId: Id<"files">, + now = Date.now(), +) { + let file = await ctx.db.get(fileId); + if (!isSiteAsset(file) || file.assetState === "deleting") return null; + if (await isSiteAssetReferenced(ctx, file)) { + await attachSiteAsset(ctx, file.siteId, file._id, now); + return null; + } + if ( + file.assetState === "attached" || + file.assetPurgeAfter === undefined || + file.assetPurgeAfter > now + ) { + return null; + } + if (file.assetState === "pending") { + await retireSiteAsset(ctx, file, now, now); + file = await ctx.db.get(fileId); + if (!isSiteAsset(file)) return null; + } + await ctx.db.patch(file._id, { + assetState: "deleting", + assetPurgeError: undefined, + }); + return { fileId: file._id, objectKey: file.objectKey }; +} diff --git a/packages/backend/convex/published.ts b/packages/backend/convex/published.ts index cc46e9c4..f1c5564d 100644 --- a/packages/backend/convex/published.ts +++ b/packages/backend/convex/published.ts @@ -87,6 +87,9 @@ function projectAccessibleSite( logoUrl: release.logoFileId ? `/api/files/${release.logoFileId}` : undefined, + faviconUrl: release.faviconFileId + ? `/api/files/${release.faviconFileId}` + : undefined, visibility: site.visibility, settings: release.settings, updatedAt: release.createdAt, @@ -373,7 +376,9 @@ export const getFavicon = query({ args.siteSlug, ); if (!resolved || !isPubliclyPublishedSite(resolved.site)) return null; - return resolved.release.settings.favicon ?? null; + return resolved.release.faviconFileId + ? `/api/files/${resolved.release.faviconFileId}` + : null; }, }); diff --git a/packages/backend/convex/releasePublication.ts b/packages/backend/convex/releasePublication.ts index d6979802..43844211 100644 --- a/packages/backend/convex/releasePublication.ts +++ b/packages/backend/convex/releasePublication.ts @@ -6,6 +6,7 @@ import { workflows } from "./workflows"; import { fileSourceVersion } from "./model/fileExtraction"; import { buildReleaseChangeDetail } from "./model/releaseChangeDetails"; import { extractionIsPublishable } from "./model/releaseState"; +import { isSiteAssetReferencedByDraft } from "./model/siteAssets"; import { extractOpenEditorText, parseOpenEditorDocument, @@ -201,6 +202,12 @@ async function snapshotFiles( .paginate({ cursor: cursor ?? null, numItems: FILE_BATCH_SIZE }); for (const source of page.page) { if (source.deletedAt !== undefined) continue; + if ( + source.kind === "siteAsset" && + !(await isSiteAssetReferencedByDraft(ctx, source)) + ) { + continue; + } await ctx.db.insert("releaseFiles", { releaseId: release._id, siteId: release.siteId, diff --git a/packages/backend/convex/releases.ts b/packages/backend/convex/releases.ts index b24af140..abdfb821 100644 --- a/packages/backend/convex/releases.ts +++ b/packages/backend/convex/releases.ts @@ -370,6 +370,7 @@ export const publish = mutation({ number, name: site.name, logoFileId: site.logoFileId, + faviconFileId: site.faviconFileId, defaultPageId: site.defaultPageId, settings: site.settings, sourceDraftRevision: draftRevision, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 4ad088fb..7059176b 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -24,8 +24,8 @@ export default defineSchema({ organizationId: v.string(), name: v.string(), slug: v.string(), - logoUrl: v.optional(v.string()), logoFileId: v.optional(v.id("files")), + faviconFileId: v.optional(v.id("files")), defaultPageId: v.optional(v.id("pages")), createdBy: v.string(), createdAt: v.number(), @@ -268,8 +268,21 @@ export default defineSchema({ uploadedBy: v.string(), createdAt: v.number(), deletedAt: v.optional(v.number()), + assetState: v.optional( + v.union( + v.literal("pending"), + v.literal("attached"), + v.literal("retired"), + v.literal("deleting"), + ), + ), + assetExpiresAt: v.optional(v.number()), + assetAttachedAt: v.optional(v.number()), + assetPurgeAfter: v.optional(v.number()), + assetPurgeError: v.optional(v.string()), }) .index("by_site", ["siteId"]) + .index("by_asset_state_purge", ["kind", "assetState", "assetPurgeAfter"]) .index("by_site_kind", ["siteId", "kind"]) .index("by_library", ["libraryId"]) .index("by_folder", ["libraryId", "folderId"]), @@ -337,6 +350,7 @@ export default defineSchema({ number: v.number(), name: v.string(), logoFileId: v.optional(v.id("files")), + faviconFileId: v.optional(v.id("files")), defaultPageId: v.optional(v.id("pages")), settings: siteSettings, sourceDraftRevision: v.number(), @@ -440,6 +454,7 @@ export default defineSchema({ }) .index("by_release", ["releaseId"]) .index("by_release_file", ["releaseId", "fileId"]) + .index("by_file", ["fileId"]) .index("by_release_library", ["releaseId", "libraryId"]), releaseChanges: defineTable({ diff --git a/packages/backend/convex/siteAssetLifecycle.ts b/packages/backend/convex/siteAssetLifecycle.ts new file mode 100644 index 00000000..4a59d663 --- /dev/null +++ b/packages/backend/convex/siteAssetLifecycle.ts @@ -0,0 +1,145 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { + internalMutation, + internalQuery, + mutation, + type MutationCtx, +} from "./_generated/server"; +import { + claimSiteAssetForPurge, + isSiteAssetReferenced, + reconcileSiteAsset, +} from "./model/siteAssets"; +import { recordStorageUsageEvent } from "./model/storageTelemetry"; +import { requireOrganizationPermission } from "./permissions"; + +async function requireManagedAsset(ctx: MutationCtx, fileId: string) { + const id = ctx.db.normalizeId("files", fileId); + const file = id ? await ctx.db.get(id) : null; + if (file?.kind !== "siteAsset") throw new Error("Asset not found"); + const site = await ctx.db.get(file.siteId); + if (!site) throw new Error("Site not found"); + await requireOrganizationPermission(ctx, site.organizationId, { + resource: "site", + action: "manage", + }); + return file; +} + +export const discard = mutation({ + args: { fileId: v.string() }, + returns: v.boolean(), + handler: async (ctx, { fileId }) => { + const file = await requireManagedAsset(ctx, fileId); + if (file.assetState !== "pending") return false; + if (await isSiteAssetReferenced(ctx, file)) return false; + await reconcileSiteAsset(ctx, file._id, { abandonPending: true }); + await ctx.scheduler.runAfter(0, internal.siteAssetPurge.purge, { + fileId: file._id, + }); + return true; + }, +}); + +export const claim = internalMutation({ + args: { fileId: v.optional(v.id("files")) }, + handler: async (ctx, { fileId }) => { + if (fileId) return claimSiteAssetForPurge(ctx, fileId); + const now = Date.now(); + for (const state of ["pending", "retired"] as const) { + const candidate = await ctx.db + .query("files") + .withIndex("by_asset_state_purge", (query) => + query + .eq("kind", "siteAsset") + .eq("assetState", state) + .lte("assetPurgeAfter", now), + ) + .first(); + if (candidate) return claimSiteAssetForPurge(ctx, candidate._id, now); + } + return null; + }, +}); + +export const completePurge = internalMutation({ + args: { fileId: v.id("files") }, + handler: async (ctx, { fileId }) => { + const file = await ctx.db.get(fileId); + if (file?.kind !== "siteAsset" || file.assetState !== "deleting") { + return false; + } + const site = await ctx.db.get(file.siteId); + if (site) { + await recordStorageUsageEvent(ctx, { + organizationId: site.organizationId, + siteId: site._id, + fileId, + kind: "purge", + bytes: file.size, + idempotencyKey: `file:purge:${fileId}`, + }); + } + await ctx.db.delete(fileId); + return true; + }, +}); + +export const failPurge = internalMutation({ + args: { fileId: v.id("files"), failure: v.string() }, + handler: async (ctx, { fileId, failure }) => { + const file = await ctx.db.get(fileId); + if (file?.kind !== "siteAsset" || file.assetState !== "deleting") { + return; + } + await ctx.db.patch(fileId, { + assetState: "retired", + assetPurgeAfter: Date.now() + 60 * 60 * 1000, + assetPurgeError: failure.replaceAll(/[\r\n\t]+/gu, " ").slice(0, 300), + }); + }, +}); + +export const audit = internalQuery({ + args: {}, + handler: async (ctx) => { + const files = await ctx.db + .query("files") + .withIndex("by_asset_state_purge", (query) => + query.eq("kind", "siteAsset"), + ) + .collect(); + const now = Date.now(); + const states = { + pending: 0, + attached: 0, + retired: 0, + deleting: 0, + legacy: 0, + due: 0, + referenced: 0, + referencedButInactive: 0, + unreferencedButAttached: 0, + }; + for (const file of files) { + if (file.assetState) states[file.assetState] += 1; + else states.legacy += 1; + if (file.assetPurgeAfter !== undefined && file.assetPurgeAfter <= now) { + states.due += 1; + } + const referenced = await isSiteAssetReferenced(ctx, file); + if (referenced) states.referenced += 1; + if ( + referenced && + (file.assetState !== "attached" || file.deletedAt !== undefined) + ) { + states.referencedButInactive += 1; + } + if (!referenced && file.assetState === "attached") { + states.unreferencedButAttached += 1; + } + } + return { total: files.length, states }; + }, +}); diff --git a/packages/backend/convex/siteAssetPurge.ts b/packages/backend/convex/siteAssetPurge.ts new file mode 100644 index 00000000..876f2242 --- /dev/null +++ b/packages/backend/convex/siteAssetPurge.ts @@ -0,0 +1,37 @@ +"use node"; + +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { internalAction } from "./_generated/server"; +import { getStorage } from "./storage"; + +const MAX_PURGES_PER_RUN = 50; + +export const purge = internalAction({ + args: { fileId: v.optional(v.id("files")) }, + handler: async (ctx, { fileId }) => { + let purged = 0; + for (let index = 0; index < MAX_PURGES_PER_RUN; index += 1) { + const claimed = await ctx.runMutation(internal.siteAssetLifecycle.claim, { + fileId: index === 0 ? fileId : undefined, + }); + if (!claimed) break; + try { + await getStorage().delete(claimed.objectKey); + await ctx.runMutation(internal.siteAssetLifecycle.completePurge, { + fileId: claimed.fileId, + }); + purged += 1; + } catch (error) { + await ctx.runMutation(internal.siteAssetLifecycle.failPurge, { + fileId: claimed.fileId, + failure: + error instanceof Error ? error.message : "Storage deletion failed", + }); + if (fileId) break; + } + if (fileId) break; + } + return { purged }; + }, +}); diff --git a/packages/backend/convex/sites.ts b/packages/backend/convex/sites.ts index 271fc14c..96180946 100644 --- a/packages/backend/convex/sites.ts +++ b/packages/backend/convex/sites.ts @@ -1,7 +1,6 @@ import { v } from "convex/values"; import { normalizeBrandColor } from "@baseblocks/domain/site-theme"; -import type { Doc, Id } from "./_generated/dataModel"; -import { query, mutation, type MutationCtx } from "./_generated/server"; +import { query, mutation } from "./_generated/server"; import { requireOrganizationPermission, isOrganizationMember, @@ -10,27 +9,7 @@ import { getAuthOrganizationById } from "./authComponent/model"; import { siteSidebarVariant, siteThemeSettings } from "./validators/sites"; import { assertDraftWritable, touchSiteDraft } from "./model/draft"; import { deleteSiteData } from "./model/siteDeletion"; -import { recordStorageUsageEvent } from "./model/storageTelemetry"; - -async function softDeleteSiteAsset( - ctx: MutationCtx, - site: Doc<"sites">, - fileId: Id<"files">, -) { - const file = await ctx.db.get(fileId); - if (!file || file.deletedAt !== undefined) return; - const now = Date.now(); - await recordStorageUsageEvent(ctx, { - organizationId: site.organizationId, - siteId: site._id, - fileId: file._id, - kind: "softDelete", - bytes: file.size, - idempotencyKey: `file:delete:${file._id}:${now}`, - now, - }); - await ctx.db.patch(file._id, { deletedAt: now }); -} +import { attachSiteAsset, reconcileSiteAsset } from "./model/siteAssets"; export const listByTeam = query({ args: { organizationId: v.string() }, @@ -199,12 +178,12 @@ export const update = mutation({ siteId: v.id("sites"), name: v.optional(v.string()), logoFileId: v.optional(v.id("files")), + faviconFileId: v.optional(v.id("files")), clearLogo: v.optional(v.boolean()), clearFavicon: v.optional(v.boolean()), settings: v.optional( v.object({ expandNavigationByDefault: v.optional(v.boolean()), - favicon: v.optional(v.string()), sidebarVariant: v.optional(siteSidebarVariant), showLogo: v.optional(v.boolean()), showSiteName: v.optional(v.boolean()), @@ -215,7 +194,15 @@ export const update = mutation({ }, handler: async ( ctx, - { siteId, name, logoFileId, clearLogo, clearFavicon, settings }, + { + siteId, + name, + logoFileId, + faviconFileId, + clearLogo, + clearFavicon, + settings, + }, ) => { const site = await ctx.db.get(siteId); if (!site) throw new Error("Site not found"); @@ -225,46 +212,35 @@ export const update = mutation({ action: "manage", }); - const updates: Record = { updatedAt: Date.now() }; + const now = Date.now(); + const updates: Record = { updatedAt: now }; if (name !== undefined) updates.name = name; if (clearLogo && logoFileId !== undefined) { throw new Error("Cannot replace and remove a site logo simultaneously"); } + if (clearFavicon && faviconFileId !== undefined) { + throw new Error("Cannot replace and remove a favicon simultaneously"); + } if (logoFileId !== undefined) { - const logoFile = await ctx.db.get(logoFileId); - if ( - !logoFile || - logoFile.siteId !== siteId || - logoFile.kind !== "siteAsset" - ) { - throw new Error("Invalid site logo asset"); - } + await attachSiteAsset(ctx, siteId, logoFileId, now); } - - if ( - logoFileId !== undefined && - site.logoFileId && - site.logoFileId !== logoFileId - ) { - await softDeleteSiteAsset(ctx, site, site.logoFileId); + if (faviconFileId !== undefined) { + await attachSiteAsset(ctx, siteId, faviconFileId, now); } if (logoFileId !== undefined) { updates.logoFileId = logoFileId; - updates.logoUrl = `/api/files/${logoFileId}`; } + if (faviconFileId !== undefined) updates.faviconFileId = faviconFileId; if (clearLogo) { - if (site.logoFileId) { - await softDeleteSiteAsset(ctx, site, site.logoFileId); - } updates.logoFileId = undefined; - updates.logoUrl = undefined; } + if (clearFavicon) updates.faviconFileId = undefined; - if (settings !== undefined || clearFavicon) { + if (settings !== undefined) { let normalizedSettings = settings; if (settings?.theme?.brandColor) { const brandColor = normalizeBrandColor(settings.theme.brandColor); @@ -275,11 +251,19 @@ export const update = mutation({ }; } const nextSettings = { ...site.settings, ...normalizedSettings }; - if (clearFavicon) delete nextSettings.favicon; updates.settings = nextSettings; } await ctx.db.patch(siteId, updates); + for (const previousFileId of [site.logoFileId, site.faviconFileId]) { + if ( + previousFileId && + previousFileId !== logoFileId && + previousFileId !== faviconFileId + ) { + await reconcileSiteAsset(ctx, previousFileId, { now }); + } + } await touchSiteDraft(ctx, siteId, Date.now(), [ { entityType: "site", entityId: siteId }, ...(site.logoFileId && @@ -287,6 +271,11 @@ export const update = mutation({ (logoFileId !== undefined && site.logoFileId !== logoFileId)) ? [{ entityType: "file" as const, entityId: site.logoFileId }] : []), + ...(site.faviconFileId && + (clearFavicon || + (faviconFileId !== undefined && site.faviconFileId !== faviconFileId)) + ? [{ entityType: "file" as const, entityId: site.faviconFileId }] + : []), ]); return siteId; diff --git a/packages/backend/convex/storage.ts b/packages/backend/convex/storage.ts new file mode 100644 index 00000000..a33cd936 --- /dev/null +++ b/packages/backend/convex/storage.ts @@ -0,0 +1,41 @@ +"use node"; + +import { Files } from "files-sdk"; +import { s3 } from "files-sdk/s3"; + +function requiredEnv(name: string): string { + const value = globalThis.process.env[name]?.trim(); + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +function forcePathStyle(): boolean { + const value = + globalThis.process.env.FILES_FORCE_PATH_STYLE?.trim().toLowerCase(); + if (!value || value === "true") return true; + if (value === "false") return false; + throw new Error("FILES_FORCE_PATH_STYLE must be true or false"); +} + +let storage: Files | undefined; + +export function getStorage(): Files { + if (storage) return storage; + const adapter = globalThis.process.env.FILES_ADAPTER?.trim() || "s3"; + if (adapter !== "s3") { + throw new Error(`Unsupported FILES_ADAPTER "${adapter}"`); + } + storage = new Files({ + adapter: s3({ + bucket: requiredEnv("FILES_BUCKET"), + endpoint: requiredEnv("FILES_ENDPOINT"), + region: requiredEnv("FILES_REGION"), + forcePathStyle: forcePathStyle(), + credentials: { + accessKeyId: requiredEnv("FILES_ACCESS_KEY_ID"), + secretAccessKey: requiredEnv("FILES_SECRET_ACCESS_KEY"), + }, + }), + }); + return storage; +} diff --git a/packages/backend/convex/validators/sites.ts b/packages/backend/convex/validators/sites.ts index adbbdff7..266a2110 100644 --- a/packages/backend/convex/validators/sites.ts +++ b/packages/backend/convex/validators/sites.ts @@ -26,7 +26,6 @@ export const siteSidebarVariant = v.union( export const siteSettings = v.object({ expandNavigationByDefault: v.optional(v.boolean()), - favicon: v.optional(v.string()), sidebarVariant: v.optional(siteSidebarVariant), showLogo: v.optional(v.boolean()), showSiteName: v.optional(v.boolean()), diff --git a/packages/custom-blocks/src/quick-links-editor.tsx b/packages/custom-blocks/src/quick-links-editor.tsx index 54900fe3..f6a7bcdf 100644 --- a/packages/custom-blocks/src/quick-links-editor.tsx +++ b/packages/custom-blocks/src/quick-links-editor.tsx @@ -32,7 +32,10 @@ import { destinationLabel, type QuickLink } from "./quick-links"; import { BlockShell } from "./ui"; const createId = () => crypto.randomUUID(); -type LinkDraft = Omit & { id: string | null }; +type LinkDraft = Omit & { + id: string | null; + initialImageAssetId?: string; +}; const emptyDraft = (): LinkDraft => ({ id: null, @@ -48,6 +51,14 @@ export const quickLinksEditor = defineOpenEditorCustomBlockEditor({ const resolved = draft ? host.links?.resolve({ href: draft.url, kind: "website" }) : null; + const discardUncommittedImage = (value: LinkDraft) => { + if ( + value.imageAssetId && + value.imageAssetId !== value.initialImageAssetId + ) { + void discardAsset(host, value.imageAssetId); + } + }; return ( @@ -65,7 +76,12 @@ export const quickLinksEditor = defineOpenEditorCustomBlockEditor({ aria-label={`Edit ${link.title}`} className="group flex min-h-[70px] min-w-0 items-center gap-3 rounded-2xl bg-card p-3 text-left transition-[transform,background-color] hover:-translate-y-0.5 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" key={link.id} - onClick={() => setDraft({ ...link })} + onClick={() => + setDraft({ + ...link, + initialImageAssetId: link.imageAssetId, + }) + } type="button" > @@ -97,7 +113,10 @@ export const quickLinksEditor = defineOpenEditorCustomBlockEditor({ { - if (!open) setDraft(null); + if (!open && draft) { + discardUncommittedImage(draft); + setDraft(null); + } }} open={draft !== null} > @@ -170,8 +189,17 @@ export const quickLinksEditor = defineOpenEditorCustomBlockEditor({ className="group flex h-20 w-28 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-muted-foreground/25 bg-background text-muted-foreground transition-[border-color,background-color] hover:border-muted-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" onClick={async () => { const asset = await host.assets?.pick?.(); - if (asset) + if (asset) { + if ( + draft.imageAssetId && + draft.imageAssetId !== + draft.initialImageAssetId && + draft.imageAssetId !== asset.id + ) { + void discardAsset(host, draft.imageAssetId); + } setDraft({ ...draft, imageAssetId: asset.id }); + } }} type="button" > @@ -199,12 +227,18 @@ export const quickLinksEditor = defineOpenEditorCustomBlockEditor({ + ) : null} + + + + + + ); +} + +function ImageField({ + imageId, + host, + onChoose, + onRemove, +}: { + imageId?: string; + host: OpenEditorCustomBlockEditorHost; + onChoose: () => void; + onRemove: () => void; +}) { + return ( +
+

Image

+
+ + {imageId ? ( + + + + + Remove + + ) : null} +
+
+ ); +} + +async function discardAsset( + host: OpenEditorCustomBlockEditorHost, + assetId: string, +) { + const assets = host.assets as + | (NonNullable & { + discard?: (id: string) => Promise; + }) + | undefined; + await assets?.discard?.(assetId); +} diff --git a/packages/custom-blocks/src/quick-link-editor-asset.tsx b/packages/custom-blocks/src/quick-link-editor-asset.tsx new file mode 100644 index 00000000..8066af75 --- /dev/null +++ b/packages/custom-blocks/src/quick-link-editor-asset.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Image01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { OpenEditorCustomBlockEditorHost } from "@openeditor/custom-block/editor"; +import { useEffect, useRef, useState } from "react"; +import { QuickLinkAssetLoader } from "./quick-link-asset-loader"; + +export function QuickLinkEditorAsset({ + assetId, + host, +}: { + assetId: string; + host: OpenEditorCustomBlockEditorHost; +}) { + const [asset, setAsset] = useState<{ src: string; alt: string } | null>(null); + const loader = useRef(new QuickLinkAssetLoader()); + useEffect(() => { + loader.current.load(assetId, host, setAsset); + return () => loader.current.cancel(); + }, [assetId, host]); + return asset ? ( + {asset.alt} + ) : ( + + ); +} diff --git a/packages/custom-blocks/src/quick-links-editor.tsx b/packages/custom-blocks/src/quick-links-editor.tsx index f6a7bcdf..7699ac7d 100644 --- a/packages/custom-blocks/src/quick-links-editor.tsx +++ b/packages/custom-blocks/src/quick-links-editor.tsx @@ -3,69 +3,30 @@ import { Add01Icon, ArrowUpRight01Icon, - Delete01Icon, - Image01Icon, Link02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Button } from "@baseblocks/ui/button"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@baseblocks/ui/dialog"; -import { Input } from "@baseblocks/ui/input"; -import { Label } from "@baseblocks/ui/label"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@baseblocks/ui/tooltip"; import { defineOpenEditorCustomBlockEditor } from "@openeditor/custom-block/editor"; -import type { OpenEditorCustomBlockEditorHost } from "@openeditor/custom-block/editor"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { quickLinksBlock } from "./index"; -import { QuickLinkAssetLoader } from "./quick-link-asset-loader"; +import { QuickLinkDialog } from "./quick-link-dialog"; +import { QuickLinkEditorAsset } from "./quick-link-editor-asset"; import { destinationLabel, type QuickLink } from "./quick-links"; import { BlockShell } from "./ui"; -const createId = () => crypto.randomUUID(); -type LinkDraft = Omit & { - id: string | null; - initialImageAssetId?: string; -}; - -const emptyDraft = (): LinkDraft => ({ - id: null, - title: "", - url: "", -}); - export const quickLinksEditor = defineOpenEditorCustomBlockEditor({ block: quickLinksBlock, render: function QuickLinksEditor({ data, host, updateData }) { - const updateDataJson = (value: unknown) => updateData(value as typeof data); - const [draft, setDraft] = useState(null); - const resolved = draft - ? host.links?.resolve({ href: draft.url, kind: "website" }) - : null; - const discardUncommittedImage = (value: LinkDraft) => { - if ( - value.imageAssetId && - value.imageAssetId !== value.initialImageAssetId - ) { - void discardAsset(host, value.imageAssetId); - } - }; + const [editingLink, setEditingLink] = useState( + null, + ); return (
- { - if (!open && draft) { - discardUncommittedImage(draft); - setDraft(null); - } - }} - open={draft !== null} - > - {draft ? ( - - - - {draft.id ? "Edit quick link" : "Add quick link"} - - -
{ - event.preventDefault(); - if (!resolved || !draft.title.trim()) return; - const value: QuickLink = { - id: draft.id ?? createId(), - title: draft.title.trim(), - url: draft.url.trim(), - imageAssetId: draft.imageAssetId, - }; - updateDataJson({ - links: draft.id - ? data.links.map((link) => - link.id === draft.id ? value : link, - ) - : [...data.links, value], - }); - setDraft(null); - }} - > -
- - - setDraft({ ...draft, title: event.target.value }) - } - value={draft.title} - /> -
-
- - - setDraft({ ...draft, url: event.target.value }) - } - placeholder="https://example.com" - value={draft.url} - /> - {draft.url && !resolved ? ( -

- Enter an HTTP, HTTPS, or site-relative URL. -

- ) : null} -
- {host.assets?.pick ? ( -
-

Image

-
- - {draft.imageAssetId ? ( - - - - - Remove - - ) : null} -
-
- ) : null} - setEditingLink(null)} + onDelete={ + editingLink === "new" + ? undefined + : () => { + updateData({ + links: data.links.filter( + ({ id }) => id !== editingLink.id, + ), + }); + setEditingLink(null); } - > - {draft.id ? ( - - ) : null} - - -
-
- ) : null} -
+ } + onSave={(value) => { + updateData({ + links: + editingLink === "new" + ? [...data.links, value] + : data.links.map((link) => + link.id === editingLink.id ? value : link, + ), + }); + setEditingLink(null); + }} + /> + ) : null}
); }, }); - -function QuickLinkEditorAsset({ - assetId, - host, -}: { - assetId: string; - host: OpenEditorCustomBlockEditorHost; -}) { - const [asset, setAsset] = useState<{ src: string; alt: string } | null>(null); - const loader = useRef(new QuickLinkAssetLoader()); - useEffect(() => { - loader.current.load(assetId, host, setAsset); - return () => loader.current.cancel(); - }, [assetId, host]); - return asset ? ( - {asset.alt} - ) : ( - - ); -} - -async function discardAsset( - host: OpenEditorCustomBlockEditorHost, - assetId: string, -) { - const assets = host.assets as - | (NonNullable & { - discard?: (id: string) => Promise; - }) - | undefined; - await assets?.discard?.(assetId); -} diff --git a/packages/domain/src/files/storage.ts b/packages/domain/src/files/storage.ts index e32f5c25..41fc243f 100644 --- a/packages/domain/src/files/storage.ts +++ b/packages/domain/src/files/storage.ts @@ -1,5 +1,9 @@ export type UploadPurpose = "file" | "siteAsset"; +export function managedFilePath(fileId: string): string { + return `/api/files/${encodeURIComponent(fileId)}`; +} + export const supportedUploadMimeTypes = [ "image/avif", "image/gif", diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ced93ec7..112f9c8f 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -59,6 +59,7 @@ export type { UploadPurpose } from "./files/storage"; export { getUploadMimeTypeForFilename, isSupportedUploadMimeType, + managedFilePath, normalizeMimeType, resolveUploadMimeType, supportedUploadMimeTypes, From 5faba63dfcd34813fed64164cf82d25841f7059e Mon Sep 17 00:00:00 2001 From: naaiyy Date: Fri, 14 Aug 2026 18:27:03 +0200 Subject: [PATCH 4/4] Resolve React compiler findings --- .../editor/settings/favicon-settings.tsx | 17 ++++++++++------- .../editor/settings/site-brand-settings.tsx | 18 +++++++++--------- .../openeditor/openeditor-page-editor.tsx | 12 +++++++----- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/web/features/editor/settings/favicon-settings.tsx b/apps/web/features/editor/settings/favicon-settings.tsx index 14898c46..d98663da 100644 --- a/apps/web/features/editor/settings/favicon-settings.tsx +++ b/apps/web/features/editor/settings/favicon-settings.tsx @@ -31,16 +31,20 @@ export function FaviconSettings({ setIsSaving(true); try { const result = await uploadImage(file, siteId); - if (!result) throw new Error("Upload failed"); - await onChange(result.fileId); - toast.success("Favicon updated"); + if (result) { + await onChange(result.fileId); + toast.success("Favicon updated"); + } else { + toast.error( + uploadState.error ?? "Unable to save the favicon. Try again.", + ); + } } catch { toast.error( uploadState.error ?? "Unable to save the favicon. Try again.", ); - } finally { - setIsSaving(false); } + setIsSaving(false); }; const remove = async () => { @@ -51,9 +55,8 @@ export function FaviconSettings({ toast.success("Favicon removed"); } catch { toast.error("Unable to remove the favicon. Try again."); - } finally { - setIsRemoving(false); } + setIsRemoving(false); }; return ( diff --git a/apps/web/features/editor/settings/site-brand-settings.tsx b/apps/web/features/editor/settings/site-brand-settings.tsx index e5e4ba2b..f7603ba6 100644 --- a/apps/web/features/editor/settings/site-brand-settings.tsx +++ b/apps/web/features/editor/settings/site-brand-settings.tsx @@ -36,9 +36,8 @@ export function SiteBrandSettings({ site }: { site: Doc<"sites"> }) { toast.success("Site name updated"); } catch { toast.error("Unable to update the site name. Try again."); - } finally { - setIsSavingName(false); } + setIsSavingName(false); }; const uploadLogo = async (file?: File) => { @@ -55,14 +54,16 @@ export function SiteBrandSettings({ site }: { site: Doc<"sites"> }) { setIsSavingLogo(true); try { const result = await uploadImage(file, siteId); - if (!result) throw new Error("Upload failed"); - await updateSite({ siteId, logoFileId: result.fileId }); - toast.success("Logo uploaded"); + if (result) { + await updateSite({ siteId, logoFileId: result.fileId }); + toast.success("Logo uploaded"); + } else { + toast.error(uploadState.error ?? "Unable to save the logo. Try again."); + } } catch { toast.error(uploadState.error ?? "Unable to save the logo. Try again."); - } finally { - setIsSavingLogo(false); } + setIsSavingLogo(false); }; const removeLogo = async () => { @@ -73,9 +74,8 @@ export function SiteBrandSettings({ site }: { site: Doc<"sites"> }) { toast.success("Logo removed"); } catch { toast.error("Unable to remove the logo. Try again."); - } finally { - setIsRemovingLogo(false); } + setIsRemovingLogo(false); }; return ( diff --git a/apps/web/features/openeditor/openeditor-page-editor.tsx b/apps/web/features/openeditor/openeditor-page-editor.tsx index 4c201da5..61943358 100644 --- a/apps/web/features/openeditor/openeditor-page-editor.tsx +++ b/apps/web/features/openeditor/openeditor-page-editor.tsx @@ -34,7 +34,7 @@ import { } from "@openeditor/ui"; import "@openeditor/ui/styles.css"; import { useMutation } from "convex/react"; -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { useBaseBlocksAttachmentRuntime } from "./attachment-runtime"; @@ -368,10 +368,12 @@ function useBaseBlocksCustomBlockConfigurations( }, ) { const discardSiteAsset = useMutation(api.siteAssetLifecycle.discard); - const assetAuthorization = useRef( - new BaseBlocksCustomBlockAssetAuthorization(document), - ).current; - assetAuthorization.updateDocument(document); + const [assetAuthorization] = useState( + () => new BaseBlocksCustomBlockAssetAuthorization(document), + ); + useEffect(() => { + assetAuthorization.updateDocument(document); + }, [assetAuthorization, document]); const pickAsset = async () => { const input = await imageRuntime.selectImage?.(); if (!input || !imageRuntime.uploadImage) return null;