mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
added image attachment in posts
This commit is contained in:
parent
359cc085b6
commit
faed5ad365
12 changed files with 3564 additions and 3170 deletions
|
|
@ -1,6 +1,9 @@
|
|||
# Path to the SQLite database file (relative to the project root).
|
||||
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=""
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ WORKDIR /app
|
|||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
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/package.json ./package.json
|
||||
|
|
|
|||
146
dev-dist/sw.js
146
dev-dist/sw.js
|
|
@ -13,76 +13,86 @@
|
|||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
let registry = {};
|
||||
|
||||
// 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.
|
||||
let nextDefineUri;
|
||||
// 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.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
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();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
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();
|
||||
}
|
||||
})
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri =
|
||||
nextDefineUri ||
|
||||
("document" in self ? document.currentScript.src : "") ||
|
||||
location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = (depUri) => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
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();
|
||||
workbox.clientsClaim();
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "/",
|
||||
"revision": "0.cgn6ae45jcc"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute(
|
||||
[
|
||||
{
|
||||
url: "/",
|
||||
revision: "0.nb364p1h8f",
|
||||
},
|
||||
],
|
||||
{},
|
||||
);
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(
|
||||
new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
|
||||
allowlist: [/^\/$/],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ services:
|
|||
- "4073:3000"
|
||||
environment:
|
||||
DATABASE_PATH: data/dsec.db
|
||||
BODY_SIZE_LIMIT: 15728640
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM: ${RESEND_FROM:-DSEC Notebook <onboarding@resend.dev>}
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import { getToken } from "$lib/stores/auth";
|
||||
import { uploadImage } from "$lib/api";
|
||||
|
||||
let {
|
||||
content = $bindable(""),
|
||||
|
|
@ -15,7 +17,10 @@
|
|||
} = $props();
|
||||
|
||||
let textarea: HTMLTextAreaElement | undefined = $state();
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
let mode = $state<"write" | "preview">("write");
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state("");
|
||||
|
||||
function selection(): { start: number; end: number; text: string } {
|
||||
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) => ``).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>
|
||||
|
||||
<div>
|
||||
|
|
@ -109,21 +172,19 @@
|
|||
<label for="markdown-editor" class="kicker mb-2 block">{label}</label>
|
||||
{/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
|
||||
type="button"
|
||||
class="{mode === 'write'
|
||||
? '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")}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="{mode === 'preview'
|
||||
? 'text-primary'
|
||||
: 'hover:text-primary'} text-sm px-2 py-2"
|
||||
class="{mode === 'preview' ? 'text-primary' : 'hover:text-primary'} px-2 py-2 text-sm"
|
||||
onclick={() => (mode = "preview")}
|
||||
>
|
||||
Preview
|
||||
|
|
@ -158,6 +219,29 @@
|
|||
H
|
||||
</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>
|
||||
|
|
@ -184,11 +268,26 @@
|
|||
bind:value={content}
|
||||
{rows}
|
||||
{placeholder}
|
||||
onpaste={handlePaste}
|
||||
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">
|
||||
Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code
|
||||
blocks.
|
||||
blocks. Paste or insert images to embed them.
|
||||
</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}
|
||||
<div
|
||||
class="border-rule bg-surface text-ink min-h-40 rounded-sm border px-3 py-2.5 text-sm leading-relaxed"
|
||||
|
|
|
|||
|
|
@ -1128,3 +1128,8 @@ export async function call(fn: string, args: Record<string, any> = {}): Promise<
|
|||
if (!handler) throw new Error(`Unknown function: ${fn}`);
|
||||
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
65
src/lib/server/images.ts
Normal 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 };
|
||||
}
|
||||
34
src/routes/api/upload/+server.ts
Normal file
34
src/routes/api/upload/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -125,7 +125,7 @@ button {
|
|||
}
|
||||
|
||||
.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 {
|
||||
|
|
|
|||
13
src/routes/uploads/[file]/+server.ts
Normal file
13
src/routes/uploads/[file]/+server.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
};
|
||||
Loading…
Reference in a new issue