The frontend is moving to a unified Canvas-based journal editor using BlockNote (document engine) + Pretext (text layout). The backend's role is:
- Image upload — new endpoint for journal image insertion
- Content storage — same BlockNote JSON, no format change
- Validation — size limits, image URL validation
- Cleanup — orphaned image deletion on journal delete
No database schema changes are required. The existing Journal.content jsonb column stores BlockNote JSON, which natively supports custom block types.
┌──────────────────────────────────┐
│ Frontend (Canvas + Pretext) │
│ │
│ Insert image → POST /api/journal │
│ /upload │
│ Auto-save → PUT /api/drafts/ │
│ :id │
│ Load draft → GET /api/drafts/ │
│ :id │
└──────────────────────────────────┘
│
▼
┌──────────────────────────────────┐
│ Backend │
│ │
│ POST /api/journal/upload │
│ → validate file │
│ → upload to S3/R2 │
│ → return URL │
│ │
│ PUT /api/drafts/:id │
│ → validate JSON size (<1MB) │
│ → validate image URLs │
│ → store in Journal.content │
│ → trigger SM ingest (existing) │
│ │
│ GET /api/drafts/:id │
│ → return Journal.content │
│ → frontend renders on Canvas │
└──────────────────────────────────┘
File: backend/src/api/journal-upload.ts (NEW)
// backend/src/api/journal-upload.ts
import type { Request, Response } from "express";
import { requireAuth } from "./middleware.js";
import { uploadToStorage, deleteFromStorage } from "../lib/storage.js";
import { log } from "../lib/log.js";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/avif",
];
export async function handleJournalImageUpload(req: Request, res: Response) {
const user = requireAuth(req);
// ── Validate file presence ──
const file = req.file;
if (!file) {
return res.status(400).json({ error: "no_file", message: "No file provided" });
}
// ── Validate type ──
if (!ALLOWED_TYPES.includes(file.mimetype as any)) {
return res.status(400).json({
error: "invalid_type",
message: `Unsupported file type: ${file.mimetype}. Allowed: JPEG, PNG, WebP, GIF, AVIF`,
});
}
// ── Validate size ──
if (file.size > MAX_FILE_SIZE) {
return res.status(400).json({
error: "too_large",
message: `File too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Max: 10MB`,
});
}
// ── Generate storage key ──
const timestamp = Date.now();
const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
const key = `journals/${user.id}/${timestamp}-${safeName}`;
// ── Upload to storage (S3 / Cloudflare R2) ──
let url: string;
try {
url = await uploadToStorage(key, file.buffer, file.mimetype);
} catch (err) {
log.error("journal image upload: storage write failed", {
userId: user.id,
key,
err: err instanceof Error ? err.message : String(err),
});
return res.status(500).json({
error: "upload_failed",
message: "Failed to store image. Please try again.",
});
}
// ── Success ──
log.info("journal image uploaded", {
userId: user.id,
key,
size: file.size,
type: file.mimetype,
});
return res.status(201).json({
url,
key,
size: file.size,
type: file.mimetype,
});
}File: backend/src/index.ts (EDIT)
// Add to existing imports
import multer from "multer";
import { handleJournalImageUpload } from "./api/journal-upload.js";
// Add after existing route registrations
const journalUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 1,
},
});
app.post(
"/api/journal/upload",
authenticateJWT, // existing auth middleware
journalUpload.single("file"),
handleJournalImageUpload,
);File: backend/src/api/handlers.ts → handleDraftsPut (EDIT)
Add image URL extraction and validation alongside the existing 1MB size check:
// ── In handleDraftsPut, after parsing content ──
const MAX_DRAFT_SIZE = 1 * 1024 * 1024; // 1MB
// New: validate image URLs in content
const imageUrls = extractImageUrls(parsedContent);
const storageBaseUrl = process.env.STORAGE_BASE_URL || process.env.S3_BUCKET_URL;
for (const url of imageUrls) {
// Ensure images reference our own storage (prevent hotlinking abuse)
if (storageBaseUrl && !url.startsWith(storageBaseUrl)) {
return res.status(400).json({
error: "invalid_image_url",
message: "Journal contains images from external sources. Please upload images via the editor.",
});
}
// Basic URL sanity check
try {
new URL(url);
} catch {
return res.status(400).json({
error: "invalid_image_url",
message: `Malformed image URL in journal content: ${url.slice(0, 100)}`,
});
}
}File: backend/src/lib/image-utils.ts (NEW)
// backend/src/lib/image-utils.ts
/**
* Recursively extract all image URLs from a BlockNote document JSON.
* Handles custom block types (floatingImage) and standard image blocks.
*/
export function extractImageUrls(content: any): string[] {
const urls: string[] = [];
function walk(node: any) {
if (!node || typeof node !== "object") return;
// Handle arrays (blocks array, children array, content array)
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
// Check for image block types
if (node.type === "image" || node.type === "floatingImage") {
if (node.props?.src && typeof node.props.src === "string") {
urls.push(node.props.src);
}
if (node.props?.url && typeof node.props.url === "string") {
urls.push(node.props.url);
}
}
// Recurse into nested content
if (node.content) walk(node.content);
if (node.children) walk(node.children);
if (node.props) walk(node.props);
}
walk(content);
return [...new Set(urls)]; // deduplicate
}File: backend/src/api/handlers.ts → journal delete handler (EDIT)
// ── In the journal delete handler ──
export async function handleDraftDelete(req: Request, res: Response) {
const user = requireAuth(req);
const { id } = req.params;
const journal = await db.journal.findUnique({
where: { id, userId: user.id },
});
if (!journal) {
return res.status(404).json({ error: "not_found" });
}
// ── Extract and delete associated images ──
const imageUrls = extractImageUrls(journal.content);
if (imageUrls.length > 0) {
log.info("journal delete: cleaning up images", {
journalId: id,
imageCount: imageUrls.length,
});
// Fire-and-forget: don't block the delete on image cleanup failures
Promise.allSettled(
imageUrls.map((url) =>
deleteFromStorage(url).catch((err) =>
log.warn("journal delete: failed to delete image", {
url: url.slice(0, 100),
err: err instanceof Error ? err.message : String(err),
})
)
)
);
}
// ── Delete the journal ──
await db.journal.delete({ where: { id } });
log.info("journal deleted", { journalId: id, userId: user.id });
return res.json({ ok: true });
}File: backend/src/lib/storage.ts (EDIT or NEW)
// backend/src/lib/storage.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
// Works with both AWS S3 and Cloudflare R2 (S3-compatible API)
const s3 = new S3Client({
region: process.env.STORAGE_REGION || "auto",
endpoint: process.env.STORAGE_ENDPOINT, // e.g. https://<account>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY!,
secretAccessKey: process.env.STORAGE_SECRET_KEY!,
},
});
const BUCKET = process.env.STORAGE_BUCKET!;
export async function uploadToStorage(
key: string,
body: Buffer,
contentType: string,
): Promise<string> {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
// Cache journal images aggressively — they don't change once uploaded
CacheControl: "public, max-age=31536000, immutable",
}),
);
// Return the public URL
const baseUrl = process.env.STORAGE_BASE_URL || process.env.STORAGE_PUBLIC_URL;
return `${baseUrl}/${key}`;
}
export async function deleteFromStorage(url: string): Promise<void> {
const baseUrl = process.env.STORAGE_BASE_URL || process.env.STORAGE_PUBLIC_URL;
const key = url.replace(`${baseUrl}/`, "");
if (!key || key === url) {
throw new Error(`Could not extract storage key from URL: ${url}`);
}
await s3.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
}),
);
}
/**
* Check that a URL points to our storage bucket.
* Used for validation when saving journal content.
*/
export function isOurStorageUrl(url: string): boolean {
const baseUrl = process.env.STORAGE_BASE_URL || process.env.STORAGE_PUBLIC_URL;
return url.startsWith(baseUrl || "");
}Not code — this is bucket-level configuration in your S3/R2 dashboard:
[
{
"AllowedOrigins": [
"https://talkamore.com",
"https://app.talkamore.com",
"http://localhost:3000"
],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600,
"ExposeHeaders": ["Content-Type", "Content-Length"]
}
]This is required for:
- Canvas-based rendering to load cross-origin images
shape-outside: url(...)to work (needs CORS)- Pretext's font measurement to not taint the canvas
Upload an image for use in the journal editor.
Request:
- Method:
POST - Content-Type:
multipart/form-data - Body:
file(single image file) - Auth: JWT Bearer token (from
useMayaAuth)
Success Response (201):
{
"url": "https://cdn.talkamore.com/journals/user_abc/1716000000-photo.jpg",
"key": "journals/user_abc/1716000000-photo.jpg",
"size": 245678,
"type": "image/jpeg"
}Error Responses:
400— no file, invalid type, or file too large401— missing/expired auth token500— storage upload failure
The existing endpoint now additionally validates image URLs in the content.
Request body (unchanged):
{
"name": "My Journal",
"content": {
"title": "Today's Entry",
"mode": "write",
"pages": [
[
{ "type": "heading", "content": [...] },
{ "type": "paragraph", "content": [...] },
{
"type": "floatingImage",
"props": {
"src": "https://cdn.talkamore.com/journals/.../photo.jpg",
"float": "left",
"width": "40%"
},
"content": []
}
]
]
}
}New validation:
- All
srcfields infloatingImageblocks must point to our storage domain - Malformed URLs are rejected with 400
The Journal model stays exactly as-is:
model Journal {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
content Json // BlockNote JSON — supports floatingImage custom blocks natively
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}BlockNote serializes custom block types (like floatingImage) into the same JSON structure. The content jsonb column stores it without any schema changes.
{
"dependencies": {
"multer": "^1.4.5-lts.1",
"@types/multer": "^1.4.12",
"@aws-sdk/client-s3": "^3.x" // if not already present
}
}talkamore-backend/
├── src/
│ ├── api/
│ │ ├── journal-upload.ts # NEW: image upload handler
│ │ └── handlers.ts # EDIT: add image URL validation + orphan cleanup on delete
│ ├── lib/
│ │ ├── image-utils.ts # NEW: extractImageUrls() utility
│ │ └── storage.ts # EDIT: add uploadToStorage, deleteFromStorage, isOurStorageUrl
│ └── index.ts # EDIT: register /api/journal/upload route + multer
└── package.json # EDIT: add multer
| Task | Effort |
|---|---|
| POST /api/journal/upload | 2 hours |
| Image URL validation in PUT drafts | 1 hour |
| extractImageUrls utility | 0.5 hour |
| Orphan cleanup on delete | 1 hour |
| Storage module (if new) | 2 hours |
| CORS config (bucket settings) | 0.5 hour |
| Total backend | ~1 day |