Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 25 additions & 1 deletion convex/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
};
})
);
},
});

6 changes: 3 additions & 3 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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 });


// ============================================================================
Expand Down
14 changes: 4 additions & 10 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,22 +382,16 @@ export default defineSchema({
})
.index("by_site", ["siteId"]),

// Image assets associated with a site. Served at https://<sitehost>/_assets/<fileName>
// and stored in Railway Bucket under "site-images/<siteId>/<fileName>".
// Filename is unique per site; re-uploads overwrite.
siteImages: defineTable({
// Files associated with a site (images, sub-pages, CSS, JS, fonts, …).
// Served at https://<sitehost>/_assets/<fileName>; stored in Railway Bucket
// under "site-assets/<siteId>/<fileName>". 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()),
})
Expand Down
100 changes: 55 additions & 45 deletions convex/siteImages.ts → convex/siteAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"),
Expand All @@ -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,
Expand All @@ -81,7 +94,7 @@ export const generateSiteImageUploadUrl = action({
},
});

export const addSiteImage = mutation({
export const addSiteAsset = mutation({
args: {
ownerDid: v.string(),
siteId: v.id("sites"),
Expand All @@ -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");
Expand All @@ -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)
)
Expand All @@ -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<void> => {
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,
});
},
});
Expand All @@ -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)
)
Expand Down
14 changes: 7 additions & 7 deletions convex/sitesHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
});
});
Loading
Loading