Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pnpm install
pnpm run dev
```

Open `http://localhost:5178` in your browser. Data persists in `data.db`, uploads in `uploads/`.
Open `http://localhost:5173` in your browser. Designs and uploads persist in the local D1 and R2 simulators under `.wrangler/`.

### Agent Mode (for OpenClaw / Claude Code)

Expand Down
11 changes: 9 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { createApp, createRoute, z } from "@clawnify/app";
import { query, get, run } from "./db.js";
import { putUpload, getUpload } from "./uploads.js";
import { initUploads, putUpload, getUpload } from "./uploads.js";

type Env = { Bindings: { DB: D1Database } };
type Env = { Bindings: { DB: D1Database; UPLOADS: R2Bucket } };

const app = createApp<Env>({ title: "OpenDesign API", version: "1.0.0" });

// Bind the R2 bucket before any handler runs. Registered ahead of every route
// because Hono runs middleware in registration order.
app.use("*", async (c, next) => {
initUploads(c.env.UPLOADS);
await next();
});

// ── Schemas ──────────────────────────────────────────────────────────

const DesignSchema = z.object({
Expand Down
67 changes: 26 additions & 41 deletions src/server/uploads.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,40 @@
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
let _bucket: R2Bucket;

const __dirname = dirname(fileURLToPath(import.meta.url));
const UPLOADS_DIR = join(__dirname, "..", "..", "uploads");
mkdirSync(UPLOADS_DIR, { recursive: true });

const MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
};

function mime(filename: string): string {
return MIME[filename.split(".").pop()?.toLowerCase() || "png"] || "application/octet-stream";
}

function sanitize(filename: string): string {
return filename.replace(/[^a-zA-Z0-9._-]/g, "");
export function initUploads(bucket: R2Bucket) {
_bucket = bucket;
}

export async function putUpload(filename: string, data: ArrayBuffer | Uint8Array, contentType: string): Promise<string> {
writeFileSync(join(UPLOADS_DIR, filename), Buffer.from(data as ArrayBuffer));
export async function putUpload(
filename: string,
data: ArrayBuffer | Uint8Array,
contentType: string,
): Promise<string> {
await _bucket.put(filename, data, { httpMetadata: { contentType } });
return `/api/uploads/${filename}`;
}

export async function getUpload(filename: string): Promise<{ data: ArrayBuffer; contentType: string } | null> {
const safe = sanitize(filename);
const filePath = join(UPLOADS_DIR, safe);
if (!existsSync(filePath)) return null;
const buf = readFileSync(filePath);
export async function getUpload(
filename: string,
): Promise<{ data: ArrayBuffer; contentType: string } | null> {
const obj = await _bucket.get(filename);
if (!obj) return null;
return {
data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
contentType: mime(safe),
data: await obj.arrayBuffer(),
contentType: obj.httpMetadata?.contentType || "application/octet-stream",
};
}

export async function deleteUpload(filename: string): Promise<void> {
const safe = sanitize(filename);
const filePath = join(UPLOADS_DIR, safe);
if (existsSync(filePath)) unlinkSync(filePath);
await _bucket.delete(filename);
}

export async function readUploadAsBase64DataUrl(filename: string): Promise<string | null> {
const safe = sanitize(filename);
const filePath = join(UPLOADS_DIR, safe);
if (!existsSync(filePath)) return null;
const buf = readFileSync(filePath);
const contentType = mime(safe);
return `data:${contentType};base64,${buf.toString("base64")}`;
export async function readUploadAsBase64DataUrl(
filename: string,
): Promise<string | null> {
const result = await getUpload(filename);
if (!result) return null;
const bytes = new Uint8Array(result.data);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return `data:${result.contentType};base64,${btoa(binary)}`;
}
4 changes: 4 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ compatibility_flags = ["nodejs_compat"]
binding = "DB"
database_name = "open-design-db"
database_id = "local"

[[r2_buckets]]
binding = "UPLOADS"
bucket_name = "open-design-uploads"