mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 15:43:58 +00:00
Merge pull request #2 from dsec-hub/feature/images-in-post
added image attachment in posts
This commit is contained in:
commit
d3f2bc2dfb
12 changed files with 3564 additions and 3170 deletions
|
|
@ -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=""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ if (!self.define) {
|
||||||
|
|
||||||
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 => {
|
new Promise((resolve) => {
|
||||||
if ("document" in self) {
|
if ("document" in self) {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = uri;
|
script.src = uri;
|
||||||
|
|
@ -47,27 +47,31 @@ if (!self.define) {
|
||||||
};
|
};
|
||||||
|
|
||||||
self.define = (depsNames, factory) => {
|
self.define = (depsNames, factory) => {
|
||||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
const uri =
|
||||||
|
nextDefineUri ||
|
||||||
|
("document" in self ? document.currentScript.src : "") ||
|
||||||
|
location.href;
|
||||||
if (registry[uri]) {
|
if (registry[uri]) {
|
||||||
// Module is already loading or loaded.
|
// Module is already loading or loaded.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let exports = {};
|
let exports = {};
|
||||||
const require = depUri => singleRequire(depUri, uri);
|
const require = (depUri) => singleRequire(depUri, uri);
|
||||||
const specialDeps = {
|
const specialDeps = {
|
||||||
module: { uri },
|
module: { uri },
|
||||||
exports,
|
exports,
|
||||||
require
|
require,
|
||||||
};
|
};
|
||||||
registry[uri] = Promise.all(depsNames.map(
|
registry[uri] = Promise.all(
|
||||||
depName => specialDeps[depName] || require(depName)
|
depsNames.map((depName) => specialDeps[depName] || require(depName)),
|
||||||
)).then(deps => {
|
).then((deps) => {
|
||||||
factory(...deps);
|
factory(...deps);
|
||||||
return exports;
|
return exports;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
define(['./workbox-7e5eb42b'], (function (workbox) { 'use strict';
|
define(["./workbox-7e5eb42b"], function (workbox) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
workbox.clientsClaim();
|
workbox.clientsClaim();
|
||||||
|
|
@ -76,13 +80,19 @@ define(['./workbox-7e5eb42b'], (function (workbox) { 'use strict';
|
||||||
* 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: "/",
|
||||||
|
revision: "0.nb364p1h8f",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
);
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
|
workbox.registerRoute(
|
||||||
allowlist: [/^\/$/]
|
new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
|
||||||
}));
|
allowlist: [/^\/$/],
|
||||||
|
}),
|
||||||
}));
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => ``).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"
|
||||||
|
|
|
||||||
|
|
@ -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
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 {
|
.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 {
|
||||||
|
|
|
||||||
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