diff --git a/apps/web/features/dashboard/sites/site-card.tsx b/apps/web/features/dashboard/sites/site-card.tsx index 92171493..fdf29cb5 100644 --- a/apps/web/features/dashboard/sites/site-card.tsx +++ b/apps/web/features/dashboard/sites/site-card.tsx @@ -12,6 +12,7 @@ import { getTeamSiteEditorPath } from "@/features/dashboard/routes"; import { getSiteOpenUrl } from "@/features/published-sites/urls"; import { api } from "@baseblocks/backend"; import type { Id } from "@baseblocks/backend"; +import { managedFilePath } from "@baseblocks/domain"; import { AlertDialog, AlertDialogAction, @@ -41,7 +42,7 @@ interface SiteCardProps { _id: string; name: string; slug: string; - logoUrl?: string; + logoFileId?: string; liveReleaseId?: string; team?: { _id: string; @@ -64,6 +65,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 + ? managedFilePath(site.logoFileId) + : undefined; const handleDelete = async () => { setIsDeleting(true); @@ -88,9 +92,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 +27,24 @@ 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) { + 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 upload the favicon. Try again.", + uploadState.error ?? "Unable to save the favicon. Try again.", ); - return; } - - await onChange(result.url); - toast.success("Favicon updated"); + setIsSaving(false); }; const remove = async () => { @@ -45,20 +55,19 @@ export function FaviconSettings({ toast.success("Favicon removed"); } catch { toast.error("Unable to remove the favicon. Try again."); - } finally { - setIsRemoving(false); } + setIsRemoving(false); }; return ( void upload(file)} onRemove={() => void remove()} progress={uploadState.progress?.percentage} - src={favicon} + src={faviconFileId ? managedFilePath(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..f7603ba6 100644 --- a/apps/web/features/editor/settings/site-brand-settings.tsx +++ b/apps/web/features/editor/settings/site-brand-settings.tsx @@ -3,6 +3,7 @@ import { useImageUpload } from "@/lib/files/use-image-upload"; import { api } from "@baseblocks/backend"; import type { Doc } from "@baseblocks/backend"; +import { managedFilePath } from "@baseblocks/domain"; import { Button } from "@baseblocks/ui/button"; import { Input } from "@baseblocks/ui/input"; import { Label } from "@baseblocks/ui/label"; @@ -14,16 +15,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]); @@ -38,9 +36,8 @@ export function SiteBrandSettings({ 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) => { @@ -53,17 +50,20 @@ 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 { - await updateSite({ siteId, logoFileId: result.fileId }); - toast.success("Logo uploaded"); + const result = await uploadImage(file, siteId); + 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("Unable to save the logo. Try again."); + toast.error(uploadState.error ?? "Unable to save the logo. Try again."); } + setIsSavingLogo(false); }; const removeLogo = async () => { @@ -74,9 +74,8 @@ export function SiteBrandSettings({ toast.success("Logo removed"); } catch { toast.error("Unable to remove the logo. Try again."); - } finally { - setIsRemovingLogo(false); } + setIsRemovingLogo(false); }; return ( @@ -90,7 +89,7 @@ export function SiteBrandSettings({ size="compact" > {isSavingName ? : null} - Save changes + Save name
@@ -113,22 +112,22 @@ export function SiteBrandSettings({ void uploadLogo(file)} onRemove={() => void removeLogo()} progress={uploadState.progress?.percentage} - src={site.logoUrl} + src={site.logoFileId ? managedFilePath(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-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..c44e10c8 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,61 @@ 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); + }); + + 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 064bdbcf..86e906ad 100644 --- a/apps/web/features/openeditor/custom-blocks.tsx +++ b/apps/web/features/openeditor/custom-blocks.tsx @@ -72,6 +72,33 @@ 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); + } + + discard(id: string) { + return 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, @@ -115,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< @@ -128,6 +156,7 @@ export const createBaseBlocksCustomBlockEditorConfiguration = ( authorizedAssetIds, pickAsset, runtimes, + discardAsset, )} runtimes={runtimes} /> @@ -138,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 889e3e55..61943358 100644 --- a/apps/web/features/openeditor/openeditor-page-editor.tsx +++ b/apps/web/features/openeditor/openeditor-page-editor.tsx @@ -34,14 +34,13 @@ 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"; import { - authorizeBaseBlocksCustomBlockAsset, + BaseBlocksCustomBlockAssetAuthorization, createBaseBlocksCustomBlockEditorConfiguration, - extractBaseBlocksCustomBlockAssetIds, } from "./custom-blocks"; import { createBaseBlocksCustomBlockViewerConfiguration } from "./custom-block-viewer"; import { useBaseBlocksImageRuntime } from "./image-runtime"; @@ -368,33 +367,36 @@ function useBaseBlocksCustomBlockConfigurations( pageRuntime: OpenEditorPageRuntime; }, ) { - const authorizedAssetIdsRef = useRef>( - extractBaseBlocksCustomBlockAssetIds(document), + const discardSiteAsset = useMutation(api.siteAssetLifecycle.discard); + const [assetAuthorization] = useState( + () => new BaseBlocksCustomBlockAssetAuthorization(document), ); - authorizedAssetIdsRef.current = - extractBaseBlocksCustomBlockAssetIds(document); - const authorizedAssetIds = useRef({ - has: (id: string) => authorizedAssetIdsRef.current.has(id), - }).current; + useEffect(() => { + assetAuthorization.updateDocument(document); + }, [assetAuthorization, 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, ); }; + const discardAsset = async (id: string) => { + if (!assetAuthorization.discard(id)) return; + await discardSiteAsset({ fileId: id }).catch(() => undefined); + }; return { editor: createBaseBlocksCustomBlockEditorConfiguration( - authorizedAssetIds, + assetAuthorization, pickAsset, runtimes, + discardAsset, ), viewer: createBaseBlocksCustomBlockViewerConfiguration( - authorizedAssetIdsRef.current, + assetAuthorization, runtimes, ), }; 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..96d10754 100644 --- a/packages/backend/convex/draftRestore.ts +++ b/packages/backend/convex/draftRestore.ts @@ -5,6 +5,7 @@ import { internalMutation, type MutationCtx } from "./_generated/server"; import { workflows } from "./workflows"; import { deleteFileRows } from "./files"; import { recordStorageUsageEvent } from "./model/storageTelemetry"; +import { attachedSiteAssetLifecycle } from "./model/siteAssets"; import { reconcileRestoredFile } from "./fileExtraction"; import { removePageContentIndex, indexPageContent } from "./search"; import { synchronizeParentDocument } from "./model/pageHierarchy"; @@ -208,6 +209,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 +444,9 @@ async function restoreFiles( folderId: snapshot.folderId, order: snapshot.order, deletedAt: undefined, + ...(snapshot.kind === "siteAsset" + ? attachedSiteAssetLifecycle(previous?.assetAttachedAt ?? Date.now()) + : {}), }); const current = await ctx.db.get(snapshot.fileId); if (current) await reconcileRestoredFile(ctx, current); @@ -487,9 +502,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..ff958157 100644 --- a/packages/backend/convex/files.ts +++ b/packages/backend/convex/files.ts @@ -1,6 +1,7 @@ import { isSupportedUploadMimeType, keyMatchesPurpose, + managedFilePath, parseFileKey, resolveUploadMimeType, } from "@baseblocks/domain"; @@ -23,6 +24,7 @@ import { upsertDraftFileSearch, } from "./search"; import { recordStorageUsageEvent } from "./model/storageTelemetry"; +import { pendingSiteAssetLifecycle } from "./model/siteAssets"; async function isFileReferencedByAccessiblePage( ctx: Parameters[0], @@ -41,7 +43,7 @@ async function isFileReferencedByAccessiblePage( } export function buildFileUrl(fileId: Id<"files">): string { - return `/api/files/${fileId}`; + return managedFilePath(fileId); } export async function deleteFileRows( @@ -467,6 +469,7 @@ export const createSiteAsset = mutation({ site.organizationId, { resource: "site", action: "manage" }, ); + const createdAt = Date.now(); const fileId = await ctx.db.insert("files", { siteId: args.siteId, kind: "siteAsset", @@ -478,7 +481,8 @@ export const createSiteAsset = mutation({ checksum: args.checksum, order: 0, uploadedBy: auth.userId, - createdAt: Date.now(), + createdAt, + ...pendingSiteAssetLifecycle(createdAt), }); 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..6f091128 --- /dev/null +++ b/packages/backend/convex/model/siteAssets.test.ts @@ -0,0 +1,116 @@ +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" | "deleting"; + purgeAfter?: number; +} = {}) { + const patches: Array> = []; + const file = { + _id: "file-1", + siteId: "site-1", + kind: "siteAsset", + objectKey: "site-assets/file-1.png", + 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, + }); + }); + + test("does not reclaim an active delete lease", async () => { + const { ctx, patches } = lifecycleContext({ + state: "deleting", + purgeAfter: 300, + }); + const claimed = await claimSiteAssetForPurge(ctx, "file-1" as never, 200); + expect(claimed).toBeNull(); + expect(patches).toEqual([]); + }); + + test("reclaims an expired delete lease", async () => { + const { ctx, patches } = lifecycleContext({ + state: "deleting", + purgeAfter: 100, + }); + const claimed = await claimSiteAssetForPurge(ctx, "file-1" as never, 200); + expect(claimed?.fileId).toBe("file-1" as never); + expect(claimed?.objectKey).toBe("site-assets/file-1.png"); + expect(patches.at(-1)).toMatchObject({ + assetState: "deleting", + assetPurgeAfter: 15 * 60 * 1000 + 200, + }); + }); +}); diff --git a/packages/backend/convex/model/siteAssets.ts b/packages/backend/convex/model/siteAssets.ts new file mode 100644 index 00000000..73769249 --- /dev/null +++ b/packages/backend/convex/model/siteAssets.ts @@ -0,0 +1,276 @@ +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; +export const SITE_ASSET_DELETE_LEASE_MS = 15 * 60 * 1000; + +type SiteAssetLifecycle = Pick< + Doc<"files">, + | "assetState" + | "assetExpiresAt" + | "assetAttachedAt" + | "assetPurgeAfter" + | "assetPurgeError" +>; + +export function pendingSiteAssetLifecycle(now: number): SiteAssetLifecycle { + const expiresAt = now + PENDING_SITE_ASSET_TTL_MS; + return { + assetState: "pending", + assetExpiresAt: expiresAt, + assetAttachedAt: undefined, + assetPurgeAfter: expiresAt, + assetPurgeError: undefined, + }; +} + +export function attachedSiteAssetLifecycle( + attachedAt: number, +): SiteAssetLifecycle { + return { + assetState: "attached", + assetExpiresAt: undefined, + assetAttachedAt: attachedAt, + assetPurgeAfter: undefined, + assetPurgeError: undefined, + }; +} + +function retiredSiteAssetLifecycle( + attachedAt: number | undefined, + purgeAfter: number, + purgeError?: string, +): SiteAssetLifecycle { + return { + assetState: "retired", + assetExpiresAt: undefined, + assetAttachedAt: attachedAt, + assetPurgeAfter: purgeAfter, + assetPurgeError: purgeError, + }; +} + +function deletingSiteAssetLifecycle( + attachedAt: number | undefined, + now: number, +): SiteAssetLifecycle { + return { + assetState: "deleting", + assetExpiresAt: undefined, + assetAttachedAt: attachedAt, + assetPurgeAfter: now + SITE_ASSET_DELETE_LEASE_MS, + assetPurgeError: undefined, + }; +} + +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, { + ...attachedSiteAssetLifecycle(file.assetAttachedAt ?? now), + 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, { + ...retiredSiteAssetLifecycle(file.assetAttachedAt, purgeAfter), + 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)) return null; + if (file.assetState === "deleting") { + if (file.assetPurgeAfter === undefined || file.assetPurgeAfter > now) { + return null; + } + await ctx.db.patch( + file._id, + deletingSiteAssetLifecycle(file.assetAttachedAt, now), + ); + return { fileId: file._id, objectKey: file.objectKey }; + } + 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, + deletingSiteAssetLifecycle(file.assetAttachedAt, now), + ); + return { fileId: file._id, objectKey: file.objectKey }; +} + +export async function retrySiteAssetPurge( + ctx: WriteCtx, + fileId: Id<"files">, + failure: string, + now = Date.now(), +) { + const file = await ctx.db.get(fileId); + if (!isSiteAsset(file) || file.assetState !== "deleting") return; + await ctx.db.patch( + fileId, + retiredSiteAssetLifecycle( + file.assetAttachedAt, + now + RETIRED_SITE_ASSET_GRACE_MS, + failure.replaceAll(/[\r\n\t]+/gu, " ").slice(0, 300), + ), + ); +} 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..d19136f9 --- /dev/null +++ b/packages/backend/convex/siteAssetLifecycle.ts @@ -0,0 +1,138 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { + internalMutation, + internalQuery, + mutation, + type MutationCtx, +} from "./_generated/server"; +import { + claimSiteAssetForPurge, + isSiteAssetReferenced, + reconcileSiteAsset, + retrySiteAssetPurge, +} 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 ["deleting", "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 }) => { + await retrySiteAssetPurge(ctx, fileId, failure); + }, +}); + +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-link-dialog.tsx b/packages/custom-blocks/src/quick-link-dialog.tsx new file mode 100644 index 00000000..fe440706 --- /dev/null +++ b/packages/custom-blocks/src/quick-link-dialog.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { Delete01Icon, Image01Icon } 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 type { OpenEditorCustomBlockEditorHost } from "@openeditor/custom-block/editor"; +import { useEffect, useRef, useState } from "react"; +import { QuickLinkEditorAsset } from "./quick-link-editor-asset"; +import type { QuickLink } from "./quick-links"; + +type DraftImage = { + originalId?: string; + currentId?: string; +}; + +type LinkDraft = { + id: string | null; + title: string; + url: string; + image: DraftImage; +}; + +const createDraft = (link: QuickLink | null): LinkDraft => ({ + id: link?.id ?? null, + title: link?.title ?? "", + url: link?.url ?? "", + image: { + originalId: link?.imageAssetId, + currentId: link?.imageAssetId, + }, +}); + +export function QuickLinkDialog({ + host, + initialLink, + onClose, + onDelete, + onSave, +}: { + host: OpenEditorCustomBlockEditorHost; + initialLink: QuickLink | null; + onClose: () => void; + onDelete?: () => void; + onSave: (link: QuickLink) => void; +}) { + const [draft, setDraft] = useState(() => createDraft(initialLink)); + const draftRef = useRef(draft); + const operation = useRef(0); + const mounted = useRef(true); + const resolved = host.links?.resolve({ href: draft.url, kind: "website" }); + + useEffect( + () => () => { + mounted.current = false; + operation.current += 1; + }, + [], + ); + useEffect(() => { + draftRef.current = draft; + }, [draft]); + + const discardPending = (image: DraftImage) => { + if (image.currentId && image.currentId !== image.originalId) { + void discardAsset(host, image.currentId); + } + }; + const close = () => { + operation.current += 1; + discardPending(draft.image); + onClose(); + }; + const chooseImage = async () => { + const currentOperation = operation.current + 1; + operation.current = currentOperation; + const asset = await host.assets?.pick?.(); + if (!asset) return; + if (!mounted.current || currentOperation !== operation.current) { + await discardAsset(host, asset.id); + return; + } + const current = draftRef.current; + discardPending(current.image); + const next = { + ...current, + image: { ...current.image, currentId: asset.id }, + }; + draftRef.current = next; + setDraft(next); + }; + + return ( + !open && close()} open> + + + + {draft.id ? "Edit quick link" : "Add quick link"} + + +
{ + event.preventDefault(); + if (!resolved || !draft.title.trim()) return; + operation.current += 1; + onSave({ + id: draft.id ?? crypto.randomUUID(), + title: draft.title.trim(), + url: draft.url.trim(), + imageAssetId: draft.image.currentId, + }); + }} + > +
+ + + 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 ? ( + { + operation.current += 1; + discardPending(draft.image); + setDraft({ + ...draft, + image: { ...draft.image, currentId: undefined }, + }); + }} + /> + ) : null} + + {draft.id ? ( + + ) : 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 54900fe3..7699ac7d 100644 --- a/packages/custom-blocks/src/quick-links-editor.tsx +++ b/packages/custom-blocks/src/quick-links-editor.tsx @@ -3,58 +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 }; - -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 [editingLink, setEditingLink] = useState( + null, + ); return (
- { - if (!open) 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} - ) : ( - - ); -} 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,