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 (
+
+ );
+}
+
+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 ? (
+
+ ) : (
+
+ );
+}
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 (
-
+ }
+ 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 ? (
-
- ) : (
-
- );
-}
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,