added image attachment in posts

This commit is contained in:
liyunze 2026-09-02 09:29:07 +08:00
parent 359cc085b6
commit faed5ad365
12 changed files with 3564 additions and 3170 deletions

View file

@ -1,6 +1,9 @@
# Path to the SQLite database file (relative to the project root). # Path to the SQLite database file (relative to the project root).
DATABASE_PATH=data/dsec.db DATABASE_PATH=data/dsec.db
# Maximum request body size in bytes (image uploads up to 10MB).
BODY_SIZE_LIMIT=15728640
# Resend API key for sending verification emails (https://resend.com/api-keys). # Resend API key for sending verification emails (https://resend.com/api-keys).
RESEND_API_KEY="" RESEND_API_KEY=""

View file

@ -25,7 +25,8 @@ WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=3000 \ PORT=3000 \
DATABASE_PATH=data/dsec.db DATABASE_PATH=data/dsec.db \
BODY_SIZE_LIMIT=15728640
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
COPY --from=build /app/package.json ./package.json COPY --from=build /app/package.json ./package.json

View file

@ -13,76 +13,86 @@
// If the loader is already loaded, just stop. // If the loader is already loaded, just stop.
if (!self.define) { if (!self.define) {
let registry = {}; let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means. // Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous. // In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri; let nextDefineUri;
const singleRequire = (uri, parentUri) => { const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href; uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || ( return (
registry[uri] ||
new Promise((resolve) => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
new Promise(resolve => { .then(() => {
if ("document" in self) { let promise = registry[uri];
const script = document.createElement("script"); if (!promise) {
script.src = uri; throw new Error(`Module ${uri} didnt register its module`);
script.onload = resolve; }
document.head.appendChild(script); return promise;
} else { })
nextDefineUri = uri; );
importScripts(uri); };
resolve();
}
})
.then(() => { self.define = (depsNames, factory) => {
let promise = registry[uri]; const uri =
if (!promise) { nextDefineUri ||
throw new Error(`Module ${uri} didnt register its module`); ("document" in self ? document.currentScript.src : "") ||
} location.href;
return promise; if (registry[uri]) {
}) // Module is already loading or loaded.
); return;
}; }
let exports = {};
self.define = (depsNames, factory) => { const require = (depUri) => singleRequire(depUri, uri);
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href; const specialDeps = {
if (registry[uri]) { module: { uri },
// Module is already loading or loaded. exports,
return; require,
} };
let exports = {}; registry[uri] = Promise.all(
const require = depUri => singleRequire(depUri, uri); depsNames.map((depName) => specialDeps[depName] || require(depName)),
const specialDeps = { ).then((deps) => {
module: { uri }, factory(...deps);
exports, return exports;
require });
}; };
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
} }
define(['./workbox-7e5eb42b'], (function (workbox) { 'use strict'; define(["./workbox-7e5eb42b"], function (workbox) {
"use strict";
self.skipWaiting(); self.skipWaiting();
workbox.clientsClaim(); workbox.clientsClaim();
/** /**
* The precacheAndRoute() method efficiently caches and responds to * The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest. * requests for URLs in the manifest.
* See https://goo.gl/S9QRab * See https://goo.gl/S9QRab
*/ */
workbox.precacheAndRoute([{ workbox.precacheAndRoute(
"url": "/", [
"revision": "0.cgn6ae45jcc" {
}], {}); url: "/",
workbox.cleanupOutdatedCaches(); revision: "0.nb364p1h8f",
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), { },
allowlist: [/^\/$/] ],
})); {},
);
})); workbox.cleanupOutdatedCaches();
workbox.registerRoute(
new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
allowlist: [/^\/$/],
}),
);
});

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ services:
- "4073:3000" - "4073:3000"
environment: environment:
DATABASE_PATH: data/dsec.db DATABASE_PATH: data/dsec.db
BODY_SIZE_LIMIT: 15728640
RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM: ${RESEND_FROM:-DSEC Notebook <onboarding@resend.dev>} RESEND_FROM: ${RESEND_FROM:-DSEC Notebook <onboarding@resend.dev>}
volumes: volumes:

View file

@ -19,3 +19,16 @@ export function query(name: string, args: Record<string, any> = {}): Promise<any
export function mutation(name: string, args: Record<string, any> = {}): Promise<any> { export function mutation(name: string, args: Record<string, any> = {}): Promise<any> {
return call(name, args); return call(name, args);
} }
export async function uploadImage(token: string, file: File): Promise<string> {
const form = new FormData();
form.append("token", token);
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
throw new Error(data?.error ?? `Upload failed (${res.status})`);
}
return data.result.url as string;
}

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
import { getToken } from "$lib/stores/auth";
import { uploadImage } from "$lib/api";
let { let {
content = $bindable(""), content = $bindable(""),
@ -15,7 +17,10 @@
} = $props(); } = $props();
let textarea: HTMLTextAreaElement | undefined = $state(); let textarea: HTMLTextAreaElement | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let mode = $state<"write" | "preview">("write"); let mode = $state<"write" | "preview">("write");
let uploading = $state(false);
let uploadError = $state("");
function selection(): { start: number; end: number; text: string } { function selection(): { start: number; end: number; text: string } {
if (!textarea) return { start: content.length, end: content.length, text: "" }; if (!textarea) return { start: content.length, end: content.length, text: "" };
@ -102,6 +107,64 @@
}; };
}); });
} }
function insertText(text: string) {
applyEdit((value, start, end) => {
const next = value.slice(0, start) + text + value.slice(end);
return { value: next, start: start + text.length, end: start + text.length };
});
}
async function uploadFiles(files: File[]) {
const token = getToken();
if (!token) {
uploadError = "Please sign in to upload images";
return;
}
uploading = true;
uploadError = "";
try {
const urls: string[] = [];
for (const file of files) {
urls.push(await uploadImage(token, file));
}
insertText(urls.map((url) => `![image](${url})`).join("\n\n"));
} catch (err: any) {
uploadError = err?.message ?? "Failed to upload image";
} finally {
uploading = false;
}
}
function pickImages() {
fileInput?.click();
}
async function onFilesSelected(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const files = input.files ? Array.from(input.files) : [];
if (files.length > 0) await uploadFiles(files);
input.value = "";
}
function handlePaste(event: ClipboardEvent) {
const data = event.clipboardData;
if (!data) return;
const files: File[] = [];
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
if (item.kind === "file") {
const file = item.getAsFile();
if (file && file.type.startsWith("image/")) files.push(file);
}
}
if (files.length === 0) return;
event.preventDefault();
void uploadFiles(files);
}
</script> </script>
<div> <div>
@ -109,21 +172,19 @@
<label for="markdown-editor" class="kicker mb-2 block">{label}</label> <label for="markdown-editor" class="kicker mb-2 block">{label}</label>
{/if} {/if}
<div class="border-l border-t border-r border-rule w-fit"> <div class="border-rule w-fit border-t border-r border-l">
<button <button
type="button" type="button"
class="{mode === 'write' class="{mode === 'write'
? 'text-primary' ? 'text-primary'
: 'hover:text-primary'} text-sm px-2 py-2 border-r border-rule" : 'hover:text-primary'} border-rule border-r px-2 py-2 text-sm"
onclick={() => (mode = "write")} onclick={() => (mode = "write")}
> >
Write Write
</button> </button>
<button <button
type="button" type="button"
class="{mode === 'preview' class="{mode === 'preview' ? 'text-primary' : 'hover:text-primary'} px-2 py-2 text-sm"
? 'text-primary'
: 'hover:text-primary'} text-sm px-2 py-2"
onclick={() => (mode = "preview")} onclick={() => (mode = "preview")}
> >
Preview Preview
@ -158,6 +219,29 @@
H H
</button> </button>
<button type="button" class="editor-tool" title="Link" onclick={insertLink}>🔗</button> <button type="button" class="editor-tool" title="Link" onclick={insertLink}>🔗</button>
<button
type="button"
class="editor-tool"
title="Insert image"
onclick={pickImages}
disabled={uploading}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<path d="M21 15l-5-5L5 21"></path>
</svg>
</button>
<button type="button" class="editor-tool" title="Code block" onclick={insertCodeBlock}> <button type="button" class="editor-tool" title="Code block" onclick={insertCodeBlock}>
{`{ }`} {`{ }`}
</button> </button>
@ -184,11 +268,26 @@
bind:value={content} bind:value={content}
{rows} {rows}
{placeholder} {placeholder}
onpaste={handlePaste}
class="field resize-y"></textarea> class="field resize-y"></textarea>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
class="hidden"
onchange={onFilesSelected}
/>
<p class="text-faint mt-2 text-xs"> <p class="text-faint mt-2 text-xs">
Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code
blocks. blocks. Paste or insert images to embed them.
</p> </p>
{#if uploading}
<p class="text-muted mt-1 text-xs">Uploading image...</p>
{/if}
{#if uploadError}
<p class="text-primary mt-1 text-xs">{uploadError}</p>
{/if}
{:else} {:else}
<div <div
class="border-rule bg-surface text-ink min-h-40 rounded-sm border px-3 py-2.5 text-sm leading-relaxed" class="border-rule bg-surface text-ink min-h-40 rounded-sm border px-3 py-2.5 text-sm leading-relaxed"

View file

@ -1128,3 +1128,8 @@ export async function call(fn: string, args: Record<string, any> = {}): Promise<
if (!handler) throw new Error(`Unknown function: ${fn}`); if (!handler) throw new Error(`Unknown function: ${fn}`);
return await handler(getDb(), args ?? {}); return await handler(getDb(), args ?? {});
} }
export function getUserByToken(token: string): Record<string, any> | null {
if (!token) return null;
return usersGetByToken(getDb(), { token });
}

65
src/lib/server/images.ts Normal file
View file

@ -0,0 +1,65 @@
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { dirname, resolve, join, extname, basename } from "node:path";
import { env } from "$env/dynamic/private";
const DATA_DIR = dirname(resolve(env.DATABASE_PATH ?? "data/dsec.db"));
const UPLOAD_DIR = join(DATA_DIR, "uploads");
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const MIME_BY_EXT: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
};
function extForMime(mime: string): string | null {
switch (mime.toLowerCase()) {
case "image/png":
return ".png";
case "image/jpeg":
return ".jpg";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
default:
return null;
}
}
export interface StoredImage {
filename: string;
mime: string;
buffer: Uint8Array<ArrayBuffer>;
}
export function saveImage(buffer: Buffer, mime: string): string {
const ext = extForMime(mime);
if (!ext) throw new Error("Only PNG, JPG, GIF and WebP images are supported");
if (buffer.length === 0) throw new Error("Image is empty");
if (buffer.length > MAX_IMAGE_BYTES) throw new Error("Image is too large (max 10MB)");
mkdirSync(UPLOAD_DIR, { recursive: true });
const filename = `${randomUUID()}${ext}`;
writeFileSync(join(UPLOAD_DIR, filename), buffer);
return filename;
}
export function getImage(filename: string): StoredImage | null {
const name = basename(filename);
const ext = extname(name).toLowerCase();
const mime = MIME_BY_EXT[ext];
if (!mime) return null;
const path = join(UPLOAD_DIR, name);
if (!existsSync(path)) return null;
const data = readFileSync(path);
const buffer = new Uint8Array(data.byteLength);
buffer.set(data);
return { filename: name, mime, buffer };
}

View file

@ -0,0 +1,34 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { saveImage } from "$lib/server/images";
import { getUserByToken } from "$lib/server/api";
export const POST: RequestHandler = async ({ request }) => {
let token: string;
let file: File | null;
try {
const form = await request.formData();
token = String(form.get("token") ?? "");
const entry = form.get("file");
file = entry && typeof (entry as File).arrayBuffer === "function" ? (entry as File) : null;
} catch {
return json({ ok: false, error: "Invalid upload" }, { status: 400 });
}
if (!getUserByToken(token)) {
return json({ ok: false, error: "Not authenticated" }, { status: 401 });
}
if (!file) {
return json({ ok: false, error: "No file provided" }, { status: 400 });
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const filename = saveImage(buffer, file.type);
return json({ ok: true, result: { url: `/uploads/${filename}` } });
} catch (err: any) {
return json({ ok: false, error: err?.message ?? "Upload failed" }, { status: 400 });
}
};

View file

@ -125,7 +125,7 @@ button {
} }
.thread-branch { .thread-branch {
@apply w-3 shrink-0 cursor-pointer self-stretch border-l-2 border-rule transition hover:border-secondary hover:brightness-150; @apply border-rule hover:border-secondary w-3 shrink-0 cursor-pointer self-stretch border-l-2 transition hover:brightness-150;
} }
.markdown { .markdown {

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from "./$types";
import { getImage } from "$lib/server/images";
export const GET: RequestHandler = ({ params }) => {
const image = getImage(params.file);
if (!image) return new Response("Not found", { status: 404 });
return new Response(image.buffer, {
headers: {
"Content-Type": image.mime,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
};