diff --git a/.env.example b/.env.example index 008a3eb..7614800 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,7 @@ VITE_SITE_BASE_DOMAIN=boop.ad # BOOP_BUCKET_NAME= # BOOP_BUCKET_ACCESS_KEY_ID= # BOOP_BUCKET_SECRET_ACCESS_KEY= -# BOOP_BUCKET_ENDPOINT=https://storage.railway.app +# BOOP_BUCKET_ENDPOINT=https://t3.storageapi.dev # copy from your bucket's ENDPOINT variable # BOOP_BUCKET_REGION=auto # PostHog Analytics (optional — analytics are disabled if not set) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 7757b5b..ed1b00a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -56,7 +56,7 @@ import type * as publication from "../publication.js"; import type * as rateLimits from "../rateLimits.js"; import type * as referrals from "../referrals.js"; import type * as siteActions from "../siteActions.js"; -import type * as siteImages from "../siteImages.js"; +import type * as siteAssets from "../siteAssets.js"; import type * as siteInternals from "../siteInternals.js"; import type * as sites from "../sites.js"; import type * as sitesHttp from "../sitesHttp.js"; @@ -122,7 +122,7 @@ declare const fullApi: ApiFromModules<{ rateLimits: typeof rateLimits; referrals: typeof referrals; siteActions: typeof siteActions; - siteImages: typeof siteImages; + siteAssets: typeof siteAssets; siteInternals: typeof siteInternals; sites: typeof sites; sitesHttp: typeof sitesHttp; diff --git a/convex/dev.ts b/convex/dev.ts index abccb21..7e186ac 100644 --- a/convex/dev.ts +++ b/convex/dev.ts @@ -4,7 +4,7 @@ */ import { v } from "convex/values"; -import { internalMutation } from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; const ONE_DAY_MS = 24 * 60 * 60 * 1000; @@ -114,3 +114,27 @@ export const revokeSubscription = internalMutation({ return { action: "canceled" as const, subscriptionId: sub._id }; }, }); + +export const inspectSites = internalQuery({ + args: {}, + handler: async (ctx) => { + const sites = await ctx.db.query("sites").collect(); + return await Promise.all( + sites.map(async (site) => { + const file = await ctx.db.get(site.fileId); + const primaryHostname = site.primaryHostnameId + ? await ctx.db.get(site.primaryHostnameId) + : null; + return { + siteId: site._id, + hostname: primaryHostname?.hostname ?? null, + fileId: site.fileId, + fileBucketKey: file?.bucketKey ?? null, + fileLegacyStorageId: file?.storageId ?? null, + fileSha256: file?.sha256 ?? null, + }; + }) + ); + }, +}); + diff --git a/convex/http.ts b/convex/http.ts index 829c449..709e49d 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -28,7 +28,7 @@ import { assignItem as assignItemHttp, unassignItem as unassignItemHttp, getItem import { heartbeat as presenceHeartbeatHttp, listPresence as listPresenceHttp } from "./presenceHttp"; import { getListActivity as getListActivityHttp } from "./activityHttp"; import { stripeWebhook, createCheckout, createPortal, getSubscription } from "./billingHttp"; -import { resolveSiteHost, resolveSiteImage } from "./sitesHttp"; +import { resolveSiteHost, resolveSiteAsset } from "./sitesHttp"; const RATE_LIMITS = { initiate: { windowMs: 60000, maxAttempts: 5 }, verify: { windowMs: 60000, maxAttempts: 5 }, @@ -407,8 +407,8 @@ http.route({ path: "/api/billing/subscription", method: "OPTIONS", handler: cors // --- Hosted site endpoints --- http.route({ path: "/api/sites/resolve-host", method: "GET", handler: resolveSiteHost }); http.route({ path: "/api/sites/resolve-host", method: "OPTIONS", handler: resolveSiteHost }); -http.route({ path: "/api/sites/resolve-image", method: "GET", handler: resolveSiteImage }); -http.route({ path: "/api/sites/resolve-image", method: "OPTIONS", handler: resolveSiteImage }); +http.route({ path: "/api/sites/resolve-asset", method: "GET", handler: resolveSiteAsset }); +http.route({ path: "/api/sites/resolve-asset", method: "OPTIONS", handler: resolveSiteAsset }); // ============================================================================ diff --git a/convex/schema.ts b/convex/schema.ts index 3d0feb0..dfe4610 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -382,22 +382,16 @@ export default defineSchema({ }) .index("by_site", ["siteId"]), - // Image assets associated with a site. Served at https:///_assets/ - // and stored in Railway Bucket under "site-images//". - // Filename is unique per site; re-uploads overwrite. - siteImages: defineTable({ + // Files associated with a site (images, sub-pages, CSS, JS, fonts, …). + // Served at https:///_assets/; stored in Railway Bucket + // under "site-assets//". Filename unique per site. + siteAssets: defineTable({ siteId: v.id("sites"), fileName: v.string(), bucketKey: v.string(), contentType: v.string(), byteLength: v.number(), sha256: v.string(), - kind: v.optional(v.union( - v.literal("favicon"), - v.literal("og"), - v.literal("avatar"), - v.literal("asset"), - )), createdAt: v.number(), updatedAt: v.optional(v.number()), }) diff --git a/convex/siteImages.ts b/convex/siteAssets.ts similarity index 65% rename from convex/siteImages.ts rename to convex/siteAssets.ts index 7c08f62..672fd6f 100644 --- a/convex/siteImages.ts +++ b/convex/siteAssets.ts @@ -14,16 +14,36 @@ import { presignPut, } from "./lib/bucket"; -const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const MAX_ASSET_BYTES = 10 * 1024 * 1024; const ALLOWED_TYPES = new Set([ + // images "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", + "image/avif", "image/x-icon", "image/vnd.microsoft.icon", - "image/avif", + // web content + "text/html", + "text/html; charset=utf-8", + "text/css", + "text/css; charset=utf-8", + "text/javascript", + "text/javascript; charset=utf-8", + "application/javascript", + "application/javascript; charset=utf-8", + "application/json", + "application/json; charset=utf-8", + "text/plain", + "text/plain; charset=utf-8", + "application/wasm", + // fonts + "font/woff", + "font/woff2", + "application/font-woff", + "application/font-woff2", ]); function sanitizeFileName(fileName: string): string { @@ -39,14 +59,7 @@ function sanitizeFileName(fileName: string): string { return cleaned; } -function imageKindOrAsset(value: string | undefined) { - if (value === "favicon" || value === "og" || value === "avatar" || value === "asset") { - return value; - } - return "asset" as const; -} - -export const generateSiteImageUploadUrl = action({ +export const generateSiteAssetUploadUrl = action({ args: { ownerDid: v.string(), siteId: v.id("sites"), @@ -59,20 +72,20 @@ export const generateSiteImageUploadUrl = action({ args ): Promise<{ uploadUrl: string; bucketKey: string; fileName: string }> => { if (!ALLOWED_TYPES.has(args.contentType)) { - throw new Error(`Unsupported image type: ${args.contentType}`); + throw new Error(`Unsupported asset type: ${args.contentType}`); } - if (args.byteLength <= 0 || args.byteLength > MAX_IMAGE_BYTES) { - throw new Error("Image is empty or exceeds the 10 MB limit."); + if (args.byteLength <= 0 || args.byteLength > MAX_ASSET_BYTES) { + throw new Error("Asset is empty or exceeds the 10 MB limit."); } - const owned = await ctx.runQuery(internal.siteImages.assertOwnsSite, { + const owned = await ctx.runQuery(internal.siteAssets.assertOwnsSite, { siteId: args.siteId, ownerDid: args.ownerDid, }); if (!owned) throw new Error("Site not found"); const fileName = sanitizeFileName(args.fileName); - const key = makeBucketKey("site-images", args.siteId, fileName); + const key = makeBucketKey("site-assets", args.siteId, fileName); const uploadUrl = await presignPut(key, { contentType: args.contentType, expiresSec: 600, @@ -81,7 +94,7 @@ export const generateSiteImageUploadUrl = action({ }, }); -export const addSiteImage = mutation({ +export const addSiteAsset = mutation({ args: { ownerDid: v.string(), siteId: v.id("sites"), @@ -90,9 +103,8 @@ export const addSiteImage = mutation({ contentType: v.string(), byteLength: v.number(), sha256: v.string(), - kind: v.optional(v.string()), }, - handler: async (ctx, args): Promise<{ imageId: Id<"siteImages"> }> => { + handler: async (ctx, args): Promise<{ assetId: Id<"siteAssets"> }> => { const site = await ctx.db.get(args.siteId); if (!site || site.ownerDid !== args.ownerDid) { throw new Error("Site not found"); @@ -101,7 +113,7 @@ export const addSiteImage = mutation({ const fileName = sanitizeFileName(args.fileName); const now = Date.now(); const existing = await ctx.db - .query("siteImages") + .query("siteAssets") .withIndex("by_site_filename", (q) => q.eq("siteId", args.siteId).eq("fileName", fileName) ) @@ -113,55 +125,53 @@ export const addSiteImage = mutation({ contentType: args.contentType, byteLength: args.byteLength, sha256: args.sha256, - kind: imageKindOrAsset(args.kind), updatedAt: now, }); - return { imageId: existing._id }; + return { assetId: existing._id }; } - const imageId = await ctx.db.insert("siteImages", { + const assetId = await ctx.db.insert("siteAssets", { siteId: args.siteId, fileName, bucketKey: args.bucketKey, contentType: args.contentType, byteLength: args.byteLength, sha256: args.sha256, - kind: imageKindOrAsset(args.kind), createdAt: now, }); - return { imageId }; + return { assetId }; }, }); -export const listSiteImages = query({ +export const listSiteAssets = query({ args: { ownerDid: v.string(), siteId: v.id("sites") }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); if (!site || site.ownerDid !== args.ownerDid) return []; return await ctx.db - .query("siteImages") + .query("siteAssets") .withIndex("by_site", (q) => q.eq("siteId", args.siteId)) .order("desc") .collect(); }, }); -export const removeSiteImage = action({ +export const removeSiteAsset = action({ args: { ownerDid: v.string(), - imageId: v.id("siteImages"), + assetId: v.id("siteAssets"), }, handler: async (ctx, args): Promise => { - const image = await ctx.runQuery(internal.siteImages.getOwnedImage, { - imageId: args.imageId, + const asset = await ctx.runQuery(internal.siteAssets.getOwnedAsset, { + assetId: args.assetId, ownerDid: args.ownerDid, }); - if (!image) throw new Error("Image not found"); + if (!asset) throw new Error("Asset not found"); - await deleteObject(image.bucketKey); - await ctx.runMutation(internal.siteImages.deleteImageRow, { - imageId: args.imageId, + await deleteObject(asset.bucketKey); + await ctx.runMutation(internal.siteAssets.deleteAssetRow, { + assetId: args.assetId, }); }, }); @@ -175,29 +185,29 @@ export const assertOwnsSite = internalQuery({ }, }); -export const getOwnedImage = internalQuery({ - args: { imageId: v.id("siteImages"), ownerDid: v.string() }, +export const getOwnedAsset = internalQuery({ + args: { assetId: v.id("siteAssets"), ownerDid: v.string() }, handler: async (ctx, args) => { - const image = await ctx.db.get(args.imageId); - if (!image) return null; - const site = await ctx.db.get(image.siteId); + const asset = await ctx.db.get(args.assetId); + if (!asset) return null; + const site = await ctx.db.get(asset.siteId); if (!site || site.ownerDid !== args.ownerDid) return null; - return image; + return asset; }, }); -export const deleteImageRow = internalMutation({ - args: { imageId: v.id("siteImages") }, +export const deleteAssetRow = internalMutation({ + args: { assetId: v.id("siteAssets") }, handler: async (ctx, args) => { - await ctx.db.delete(args.imageId); + await ctx.db.delete(args.assetId); }, }); -export const resolvePublicImage = internalQuery({ +export const resolvePublicAsset = internalQuery({ args: { siteId: v.id("sites"), fileName: v.string() }, handler: async (ctx, args) => { return await ctx.db - .query("siteImages") + .query("siteAssets") .withIndex("by_site_filename", (q) => q.eq("siteId", args.siteId).eq("fileName", args.fileName) ) diff --git a/convex/sitesHttp.ts b/convex/sitesHttp.ts index 9ab5098..ae94b38 100644 --- a/convex/sitesHttp.ts +++ b/convex/sitesHttp.ts @@ -80,7 +80,7 @@ export const resolveSiteHost = httpAction(async (ctx, request) => { }); }); -export const resolveSiteImage = httpAction(async (ctx, request) => { +export const resolveSiteAsset = httpAction(async (ctx, request) => { if (request.method === "OPTIONS") { return new Response(null, { status: 204, @@ -105,18 +105,18 @@ export const resolveSiteImage = httpAction(async (ctx, request) => { return json({ status: "pending" }, 404); } - const image = await ctx.runQuery(internal.siteImages.resolvePublicImage, { + const asset = await ctx.runQuery(internal.siteAssets.resolvePublicAsset, { siteId: record.site._id, fileName, }); - if (!image) return json({ status: "missing" }, 404); + if (!asset) return json({ status: "missing" }, 404); - const presigned = await presignGet(image.bucketKey, { expiresSec: 300 }); + const presigned = await presignGet(asset.bucketKey, { expiresSec: 300 }); return json({ status: "active", - contentType: image.contentType, - byteLength: image.byteLength, - sha256: image.sha256, + contentType: asset.contentType, + byteLength: asset.byteLength, + sha256: asset.sha256, url: presigned, }); }); diff --git a/scripts/bucket-set-cors.mjs b/scripts/bucket-set-cors.mjs new file mode 100644 index 0000000..3a8619f --- /dev/null +++ b/scripts/bucket-set-cors.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env bun +/** + * Configure CORS on the Railway Bucket so the browser can PUT/GET directly + * via presigned URLs. Required once per bucket, per environment. + * + * Allows: any origin (since presigned URLs already gate access), PUT/GET/HEAD, + * common headers, and a 3000s preflight cache. + * + * Usage: bun scripts/bucket-set-cors.mjs + */ +import { AwsClient } from "aws4fetch"; + +const name = process.env.BOOP_BUCKET_NAME; +const accessKeyId = process.env.BOOP_BUCKET_ACCESS_KEY_ID; +const secretAccessKey = process.env.BOOP_BUCKET_SECRET_ACCESS_KEY; +const endpoint = process.env.BOOP_BUCKET_ENDPOINT ?? "https://storage.railway.app"; +const region = process.env.BOOP_BUCKET_REGION ?? "auto"; + +if (!name || !accessKeyId || !secretAccessKey) { + throw new Error("BOOP_BUCKET_* env vars must be set"); +} + +const aws = new AwsClient({ accessKeyId, secretAccessKey, service: "s3", region }); + +const host = new URL(endpoint).host; +const corsXml = ` + + + * + PUT + GET + HEAD + * + ETag + 3000 + +`; + +const url = `https://${name}.${host}/?cors`; +const md5 = await md5Base64(corsXml); + +console.log("→ PUT", url); +const res = await aws.fetch(url, { + method: "PUT", + headers: { + "content-type": "application/xml", + "content-md5": md5, + }, + body: corsXml, +}); +const text = await res.text(); +console.log(`status: ${res.status}`); +if (text) console.log(text); +if (!res.ok) process.exit(1); + +console.log("→ GET", url); +const getRes = await aws.fetch(url, { method: "GET" }); +console.log(`status: ${getRes.status}`); +console.log(await getRes.text()); + +async function md5Base64(s) { + const hash = await crypto.subtle.digest("MD5", new TextEncoder().encode(s)).catch(() => null); + if (hash) { + return Buffer.from(new Uint8Array(hash)).toString("base64"); + } + // Fallback if SubtleCrypto doesn't expose MD5 in this runtime. + const { createHash } = await import("node:crypto"); + return createHash("md5").update(s).digest("base64"); +} diff --git a/server.ts b/server.ts index 8409c1f..3c30b9d 100644 --- a/server.ts +++ b/server.ts @@ -106,7 +106,7 @@ async function resolveHostedSite(hostname: string) { }>; } -async function resolveSiteImageUrl( +async function resolveSiteAssetUrl( hostname: string, fileName: string ): Promise<{ status: "active" | "missing" | "pending"; url?: string; contentType?: string }> { @@ -114,10 +114,10 @@ async function resolveSiteImageUrl( if (!convexHttpUrl) return { status: "missing" }; const response = await fetch( - `${convexHttpUrl}/api/sites/resolve-image?hostname=${encodeURIComponent(hostname)}&fileName=${encodeURIComponent(fileName)}` + `${convexHttpUrl}/api/sites/resolve-asset?hostname=${encodeURIComponent(hostname)}&fileName=${encodeURIComponent(fileName)}` ); if (response.status === 404) return { status: "missing" }; - if (!response.ok) throw new Error(`Image resolve failed with ${response.status}`); + if (!response.ok) throw new Error(`Asset resolve failed with ${response.status}`); return response.json() as Promise<{ status: "active" | "missing" | "pending"; url?: string; @@ -135,13 +135,13 @@ function missingSitePage(hostname: string): Response { ); } -async function serveHostedSiteImage( +async function serveHostedSiteAsset( hostname: string, fileName: string ): Promise { - const image = await resolveSiteImageUrl(hostname, fileName); - if (image.status === "active" && image.url) { - return Response.redirect(image.url, 302); + const asset = await resolveSiteAssetUrl(hostname, fileName); + if (asset.status === "active" && asset.url) { + return Response.redirect(asset.url, 302); } return missingSitePage(hostname); } @@ -152,7 +152,7 @@ async function serveHostedSite(request: Request, hostname: string): Promise { @@ -27,24 +44,34 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB`; } -export function SiteImages({ siteId, ownerDid, hostname }: Props) { - const images = useQuery(api.siteImages.listSiteImages, { ownerDid, siteId }); - const generateUploadUrl = useAction(api.siteImages.generateSiteImageUploadUrl); - const addImage = useMutation(api.siteImages.addSiteImage); - const removeImage = useAction(api.siteImages.removeSiteImage); +function assetIcon(contentType: string): string { + if (contentType.startsWith("image/")) return "🖼"; + if (contentType.startsWith("text/html")) return "📄"; + if (contentType.startsWith("text/css")) return "🎨"; + if (contentType.includes("javascript")) return "📜"; + if (contentType.includes("json")) return "📋"; + if (contentType.startsWith("font/")) return "🔤"; + return "📁"; +} + +export function SiteAssets({ siteId, ownerDid, hostname }: Props) { + const assets = useQuery(api.siteAssets.listSiteAssets, { ownerDid, siteId }); + const generateUploadUrl = useAction(api.siteAssets.generateSiteAssetUploadUrl); + const addAsset = useMutation(api.siteAssets.addSiteAsset); + const removeAsset = useAction(api.siteAssets.removeSiteAsset); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); const { addToast } = useToast(); const { haptic } = useSettings(); const sorted = useMemo( - () => (images ?? []).slice().sort((a, b) => b.createdAt - a.createdAt), - [images] + () => (assets ?? []).slice().sort((a, b) => b.createdAt - a.createdAt), + [assets] ); const handleUpload = async (file: File) => { if (file.size > MAX_BYTES) { - addToast("That image is over the 10 MB limit.", "error"); + addToast("That file is over the 10 MB limit.", "error"); return; } setUploading(true); @@ -52,27 +79,28 @@ export function SiteImages({ siteId, ownerDid, hostname }: Props) { try { const buffer = await file.arrayBuffer(); const sha256 = await sha256Hex(buffer); + const contentType = file.type || "application/octet-stream"; const { uploadUrl, bucketKey, fileName } = await generateUploadUrl({ ownerDid, siteId, fileName: file.name, - contentType: file.type || "application/octet-stream", + contentType, byteLength: file.size, }); const putRes = await fetch(uploadUrl, { method: "PUT", - headers: { "Content-Type": file.type || "application/octet-stream" }, + headers: { "Content-Type": contentType }, body: buffer, }); if (!putRes.ok) { throw new Error(`Upload failed (${putRes.status})`); } - await addImage({ + await addAsset({ ownerDid, siteId, fileName, bucketKey, - contentType: file.type || "application/octet-stream", + contentType, byteLength: file.size, sha256, }); @@ -88,14 +116,14 @@ export function SiteImages({ siteId, ownerDid, hostname }: Props) { } }; - const handleRemove = async (image: Doc<"siteImages">) => { - if (!confirm(`Delete ${image.fileName}?`)) return; + const handleRemove = async (asset: Doc<"siteAssets">) => { + if (!confirm(`Delete ${asset.fileName}?`)) return; try { - await removeImage({ ownerDid, imageId: image._id }); - addToast(`Deleted ${image.fileName}`); + await removeAsset({ ownerDid, assetId: asset._id }); + addToast(`Deleted ${asset.fileName}`); } catch (err) { addToast( - err instanceof Error ? err.message : "Could not delete that image.", + err instanceof Error ? err.message : "Could not delete that file.", "error" ); } @@ -106,10 +134,10 @@ export function SiteImages({ siteId, ownerDid, hostname }: Props) {

- Images + Files

- Referenced from your HTML as /_assets/<filename>. + Images, sub-pages, CSS, JS — served at /_assets/<filename>.

- No images yet. + No files yet.

) : (
    - {sorted.map((image) => { + {sorted.map((asset) => { const href = hostname - ? `https://${hostname}/_assets/${image.fileName}` + ? `https://${hostname}/_assets/${asset.fileName}` : ""; return (
  • + + {assetIcon(asset.contentType)} +
    - {image.fileName} + {asset.fileName}

    - {image.contentType} · {formatBytes(image.byteLength)} + {asset.contentType} · {formatBytes(asset.byteLength)}

    - /_assets/{image.fileName} + /_assets/{asset.fileName}
{did && ( - + )}