Skip to content

Instantly share code, notes, and snippets.

@bluntbrain
Last active May 18, 2026 01:44
Show Gist options
  • Select an option

  • Save bluntbrain/ed2ca722cd59c286f8ab9719b97cb489 to your computer and use it in GitHub Desktop.

Select an option

Save bluntbrain/ed2ca722cd59c286f8ab9719b97cb489 to your computer and use it in GitHub Desktop.
Talkamore: Canvas Journal Editor — Backend Image Upload & Content Storage

Backend: Canvas Journal Editor — Image Upload & Content Storage

Overview

The frontend is moving to a unified Canvas-based journal editor using BlockNote (document engine) + Pretext (text layout). The backend's role is:

  1. Image upload — new endpoint for journal image insertion
  2. Content storage — same BlockNote JSON, no format change
  3. Validation — size limits, image URL validation
  4. 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.


Architecture

┌──────────────────────────────────┐
│  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   │
└──────────────────────────────────┘

Implementation

1. Image Upload Endpoint

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,
  });
}

2. Route Registration

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,
);

3. Content Size Validation Update

File: backend/src/api/handlers.tshandleDraftsPut (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)}`,
    });
  }
}

4. Image URL Extraction Utility

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
}

5. Orphan Image Cleanup on Journal Delete

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 });
}

6. Storage Module (if not already present)

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 || "");
}

7. CORS Configuration for Storage Bucket

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

API Specification

POST /api/journal/upload

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 large
  • 401 — missing/expired auth token
  • 500 — storage upload failure

PUT /api/drafts/:id (updated)

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 src fields in floatingImage blocks must point to our storage domain
  • Malformed URLs are rejected with 400

Database: No Changes Required

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

{
  "dependencies": {
    "multer": "^1.4.5-lts.1",
    "@types/multer": "^1.4.12",
    "@aws-sdk/client-s3": "^3.x"     // if not already present
  }
}

Files to Create/Edit

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

Effort Estimate

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment