feat: rich post editor + downloads section
Add a Markdown editor for post bodies with a formatting toolbar (bold/italic/headings/lists/code/link/image) and a live Write/Preview toggle. The body still submits as a plain field so saving works without JS. Add a per-post downloads section: upload files to the blog or attach external links. Uploads POST to /admin/upload (admin-guarded, sanitized names, 200MB cap), are stored under data/uploads, and served back via the /uploads/[name] route. Downloads render on the post page with file vs link icons and sizes, and are carried through export/import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import type { Download } from "@/lib/posts";
|
||||||
|
|
||||||
|
// Manages a post's download section: a mix of uploaded files and external links.
|
||||||
|
// Uploads POST to /admin/upload and come back as a /uploads/<name> URL; external
|
||||||
|
// links are entered by hand. The whole list is serialized to a hidden input
|
||||||
|
// (name="downloads") as JSON, mirroring the console/game pickers' pattern.
|
||||||
|
|
||||||
|
function formatSize(bytes?: number): string {
|
||||||
|
if (!bytes && bytes !== 0) return "";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let n = bytes;
|
||||||
|
let u = 0;
|
||||||
|
while (n >= 1024 && u < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
u++;
|
||||||
|
}
|
||||||
|
return `${n >= 10 || u === 0 ? Math.round(n) : n.toFixed(1)} ${units[u]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadEditor({
|
||||||
|
name,
|
||||||
|
initial,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
initial: Download[];
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<Download[]>(initial);
|
||||||
|
const [linkLabel, setLinkLabel] = useState("");
|
||||||
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function setLabel(idx: number, label: string) {
|
||||||
|
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, label } : it)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(idx: number) {
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLink() {
|
||||||
|
const url = linkUrl.trim();
|
||||||
|
if (!url) return;
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ label: linkLabel.trim() || url, url, kind: "link" },
|
||||||
|
]);
|
||||||
|
setLinkLabel("");
|
||||||
|
setLinkUrl("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFiles(files: FileList | null) {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
setError(null);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/admin/upload", { method: "POST", body: fd });
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(j.error || `upload failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const j = (await res.json()) as { url: string; filename: string; size: number };
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ label: j.filename, url: j.url, kind: "file", size: j.size, filename: j.filename },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rb-dl">
|
||||||
|
<input type="hidden" name={name} value={JSON.stringify(items)} />
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<ul className="rb-dl-list">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<li key={`${it.url}-${i}`} className="rb-dl-row">
|
||||||
|
<span className="rb-dl-kind" aria-hidden>
|
||||||
|
{it.kind === "file" ? "📦" : "🔗"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="rb-dl-label"
|
||||||
|
type="text"
|
||||||
|
value={it.label}
|
||||||
|
placeholder="Download label"
|
||||||
|
onChange={(e) => setLabel(i, e.target.value)}
|
||||||
|
/>
|
||||||
|
<a className="rb-dl-url" href={it.url} target="_blank" rel="noreferrer" title={it.url}>
|
||||||
|
{it.kind === "file" ? it.filename ?? it.url : it.url}
|
||||||
|
</a>
|
||||||
|
{it.size != null && <span className="rb-dl-size">{formatSize(it.size)}</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rb-dl-remove"
|
||||||
|
aria-label={`Remove ${it.label}`}
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rb-dl-add">
|
||||||
|
<div className="rb-dl-add-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="rb-dl-add-label"
|
||||||
|
value={linkLabel}
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
onChange={(e) => setLinkLabel(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="rb-dl-add-url"
|
||||||
|
value={linkUrl}
|
||||||
|
placeholder="https://example.com/file.zip"
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addLink();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="rb-btn rb-btn-inline" onClick={addLink}>
|
||||||
|
Add link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rb-dl-add-row">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="rb-dl-file"
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(e) => onFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
{uploading && <span className="rb-dl-status">Uploading…</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="rb-admin-error rb-dl-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
// Richer Markdown editor for the post body. A formatting toolbar wraps/inserts
|
||||||
|
// syntax around the textarea selection, and a Write/Preview toggle renders the
|
||||||
|
// same `marked` output the server uses. The textarea keeps name="body" so the
|
||||||
|
// surrounding server-action form submits it exactly as before — no JS required
|
||||||
|
// to save, the toolbar is pure enhancement.
|
||||||
|
|
||||||
|
type Wrap = { before: string; after: string; placeholder: string };
|
||||||
|
|
||||||
|
type ToolButton =
|
||||||
|
| { kind: "wrap"; label: string; title: string; wrap: Wrap }
|
||||||
|
| { kind: "prefix"; label: string; title: string; prefix: string }
|
||||||
|
| { kind: "link" }
|
||||||
|
| { kind: "image" };
|
||||||
|
|
||||||
|
const TOOLS: ToolButton[] = [
|
||||||
|
{ kind: "wrap", label: "B", title: "Bold", wrap: { before: "**", after: "**", placeholder: "bold text" } },
|
||||||
|
{ kind: "wrap", label: "I", title: "Italic", wrap: { before: "_", after: "_", placeholder: "italic text" } },
|
||||||
|
{ kind: "wrap", label: "S", title: "Strikethrough", wrap: { before: "~~", after: "~~", placeholder: "struck" } },
|
||||||
|
{ kind: "wrap", label: "</>", title: "Inline code", wrap: { before: "`", after: "`", placeholder: "code" } },
|
||||||
|
{ kind: "prefix", label: "H1", title: "Heading 1", prefix: "# " },
|
||||||
|
{ kind: "prefix", label: "H2", title: "Heading 2", prefix: "## " },
|
||||||
|
{ kind: "prefix", label: "H3", title: "Heading 3", prefix: "### " },
|
||||||
|
{ kind: "prefix", label: "“ ”", title: "Quote", prefix: "> " },
|
||||||
|
{ kind: "prefix", label: "• List", title: "Bulleted list", prefix: "- " },
|
||||||
|
{ kind: "prefix", label: "1. List", title: "Numbered list", prefix: "1. " },
|
||||||
|
{ kind: "wrap", label: "{ }", title: "Code block", wrap: { before: "```\n", after: "\n```", placeholder: "code block" } },
|
||||||
|
{ kind: "link" },
|
||||||
|
{ kind: "image" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MarkdownEditor({
|
||||||
|
name,
|
||||||
|
defaultValue = "",
|
||||||
|
rows = 18,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
rows?: number;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const [tab, setTab] = useState<"write" | "preview">("write");
|
||||||
|
|
||||||
|
// Replace the current selection and restore focus + a sensible caret/selection
|
||||||
|
// so chained formatting feels native.
|
||||||
|
function apply(transform: (sel: string) => { text: string; selStart: number; selEnd: number }) {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
const selected = value.slice(start, end);
|
||||||
|
const { text, selStart, selEnd } = transform(selected);
|
||||||
|
const next = value.slice(0, start) + text + value.slice(end);
|
||||||
|
setValue(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(start + selStart, start + selEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(w: Wrap) {
|
||||||
|
apply((sel) => {
|
||||||
|
const inner = sel || w.placeholder;
|
||||||
|
return {
|
||||||
|
text: w.before + inner + w.after,
|
||||||
|
selStart: w.before.length,
|
||||||
|
selEnd: w.before.length + inner.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefix(p: string) {
|
||||||
|
apply((sel) => {
|
||||||
|
const lines = (sel || "").split("\n");
|
||||||
|
const text = lines.map((l) => p + l).join("\n");
|
||||||
|
return { text, selStart: 0, selEnd: text.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function link() {
|
||||||
|
const url = window.prompt("Link URL", "https://");
|
||||||
|
if (!url) return;
|
||||||
|
apply((sel) => {
|
||||||
|
const label = sel || "link text";
|
||||||
|
const text = `[${label}](${url})`;
|
||||||
|
return { text, selStart: 1, selEnd: 1 + label.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function image() {
|
||||||
|
const url = window.prompt("Image URL", "https://");
|
||||||
|
if (!url) return;
|
||||||
|
apply((sel) => {
|
||||||
|
const alt = sel || "alt text";
|
||||||
|
const text = ``;
|
||||||
|
return { text, selStart: 2, selEnd: 2 + alt.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTool(t: ToolButton) {
|
||||||
|
if (t.kind === "wrap") wrap(t.wrap);
|
||||||
|
else if (t.kind === "prefix") prefix(t.prefix);
|
||||||
|
else if (t.kind === "link") link();
|
||||||
|
else image();
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewHtml = marked.parse(value || "*Nothing to preview yet.*", { async: false }) as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rb-mde">
|
||||||
|
<div className="rb-mde-bar">
|
||||||
|
<div className="rb-mde-tools">
|
||||||
|
{TOOLS.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="rb-mde-tool"
|
||||||
|
title={t.kind === "link" ? "Link" : t.kind === "image" ? "Image" : t.title}
|
||||||
|
onClick={() => onTool(t)}
|
||||||
|
disabled={tab === "preview"}
|
||||||
|
>
|
||||||
|
{t.kind === "link" ? "🔗" : t.kind === "image" ? "🖼" : t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rb-mde-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rb-mde-tab ${tab === "write" ? "is-active" : ""}`}
|
||||||
|
onClick={() => setTab("write")}
|
||||||
|
>
|
||||||
|
Write
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rb-mde-tab ${tab === "preview" ? "is-active" : ""}`}
|
||||||
|
onClick={() => setTab("preview")}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "write" ? (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
name={name}
|
||||||
|
className="rb-mono rb-mde-textarea"
|
||||||
|
rows={rows}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="rb-prose rb-mde-preview"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||||
|
/>
|
||||||
|
{/* Keep the value submittable even while previewing. */}
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import type { Post } from "@/lib/posts";
|
|||||||
import { linksToText } from "@/lib/posts";
|
import { linksToText } from "@/lib/posts";
|
||||||
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
|
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
|
||||||
import { savePostAction } from "../../actions";
|
import { savePostAction } from "../../actions";
|
||||||
|
import MarkdownEditor from "./MarkdownEditor";
|
||||||
|
import DownloadEditor from "./DownloadEditor";
|
||||||
|
|
||||||
// Server-rendered create/edit form. Both modes post to the same action; the
|
// Server-rendered create/edit form. Both modes post to the same action; the
|
||||||
// hidden `id` (present only when editing) decides insert vs update.
|
// hidden `id` (present only when editing) decides insert vs update.
|
||||||
@@ -70,17 +72,22 @@ export default function PostForm({
|
|||||||
<textarea name="excerpt" rows={2} defaultValue={post?.excerpt ?? ""} />
|
<textarea name="excerpt" rows={2} defaultValue={post?.excerpt ?? ""} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="rb-field">
|
<div className="rb-field">
|
||||||
<span>
|
<span>
|
||||||
Body <em className="rb-admin-muted">(Markdown)</em>
|
Body <em className="rb-admin-muted">(Markdown)</em>
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<MarkdownEditor name="body" defaultValue={post?.body ?? ""} />
|
||||||
name="body"
|
</div>
|
||||||
rows={16}
|
|
||||||
className="rb-mono"
|
<div className="rb-field">
|
||||||
defaultValue={post?.body ?? ""}
|
<span>
|
||||||
/>
|
Downloads{" "}
|
||||||
</label>
|
<em className="rb-admin-muted">
|
||||||
|
(upload a file to the blog, or add an external link)
|
||||||
|
</em>
|
||||||
|
</span>
|
||||||
|
<DownloadEditor name="downloads" initial={post?.downloads ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="rb-field">
|
<label className="rb-field">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function postInputFromForm(formData: FormData): PostInput {
|
|||||||
author: s(formData, "author"),
|
author: s(formData, "author"),
|
||||||
tags: s(formData, "tags"),
|
tags: s(formData, "tags"),
|
||||||
links: s(formData, "links"),
|
links: s(formData, "links"),
|
||||||
|
downloads: s(formData, "downloads"),
|
||||||
createdAt: s(formData, "createdAt"),
|
createdAt: s(formData, "createdAt"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -180,6 +181,11 @@ export async function importAction(formData: FormData) {
|
|||||||
: typeof p.tags === "string"
|
: typeof p.tags === "string"
|
||||||
? p.tags
|
? p.tags
|
||||||
: "",
|
: "",
|
||||||
|
downloads: Array.isArray(p.downloads)
|
||||||
|
? JSON.stringify(p.downloads)
|
||||||
|
: typeof p.downloads === "string"
|
||||||
|
? p.downloads
|
||||||
|
: undefined,
|
||||||
createdAt:
|
createdAt:
|
||||||
typeof p.createdAt === "string"
|
typeof p.createdAt === "string"
|
||||||
? p.createdAt
|
? p.createdAt
|
||||||
|
|||||||
@@ -625,3 +625,197 @@
|
|||||||
background: var(--a-err-bg);
|
background: var(--a-err-bg);
|
||||||
color: var(--a-danger);
|
color: var(--a-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- markdown editor (post body) ---- */
|
||||||
|
.rb-mde {
|
||||||
|
border: 1px solid var(--a-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.rb-mde-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-bottom: 1px solid var(--a-border);
|
||||||
|
}
|
||||||
|
.rb-mde-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.rb-mde-tool {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 30px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
color: var(--a-text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--a-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rb-mde-tool:hover:not(:disabled) {
|
||||||
|
background: #eef1f6;
|
||||||
|
}
|
||||||
|
.rb-mde-tool:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.rb-mde-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.rb-mde-tab {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: var(--a-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rb-mde-tab.is-active {
|
||||||
|
color: var(--a-text);
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--a-border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.rb-mde-textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
.rb-mde-textarea:focus {
|
||||||
|
outline: 2px solid var(--a-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.rb-mde-preview {
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- download editor ---- */
|
||||||
|
.rb-dl-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.rb-dl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--a-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
.rb-dl-kind {
|
||||||
|
font-size: 16px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.rb-dl-label {
|
||||||
|
flex: 1 1 40%;
|
||||||
|
min-width: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--a-text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--a-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
.rb-dl-label:focus {
|
||||||
|
outline: 2px solid var(--a-primary);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
.rb-dl-url {
|
||||||
|
flex: 1 1 30%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--a-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rb-dl-size {
|
||||||
|
flex: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--a-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rb-dl-remove {
|
||||||
|
flex: none;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--a-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rb-dl-remove:hover {
|
||||||
|
background: var(--a-err-bg);
|
||||||
|
color: var(--a-danger);
|
||||||
|
}
|
||||||
|
.rb-dl-add {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--a-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
}
|
||||||
|
.rb-dl-add-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rb-dl-add-label {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
}
|
||||||
|
.rb-dl-add-url {
|
||||||
|
flex: 2 1 240px;
|
||||||
|
}
|
||||||
|
.rb-dl-add-label,
|
||||||
|
.rb-dl-add-url {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--a-text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--a-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.rb-dl-add-label:focus,
|
||||||
|
.rb-dl-add-url:focus {
|
||||||
|
outline: 2px solid var(--a-primary);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
.rb-dl-file {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.rb-dl-status {
|
||||||
|
color: var(--a-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rb-dl-error {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export async function GET(req: NextRequest) {
|
|||||||
body: p.body,
|
body: p.body,
|
||||||
author: p.author,
|
author: p.author,
|
||||||
tags: p.tags,
|
tags: p.tags,
|
||||||
|
downloads: p.downloads,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { isAdmin } from "@/lib/auth";
|
||||||
|
import { makeStoredName, uploadsDir } from "@/lib/uploads";
|
||||||
|
|
||||||
|
// POST /admin/upload — multipart with a single `file` field. Saves the file to
|
||||||
|
// data/uploads and returns its public URL + metadata for the download editor.
|
||||||
|
// Guarded by the /admin middleware, plus a check here for defence in depth.
|
||||||
|
|
||||||
|
// Cap uploads so a single request can't exhaust the disk. 200 MB suits ROMs /
|
||||||
|
// archives while staying sane for a self-hosted blog.
|
||||||
|
const MAX_BYTES = 200 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!(await isAdmin())) {
|
||||||
|
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await req.formData();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "invalid form data" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return NextResponse.json({ error: "no file" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "file too large" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = makeStoredName(file.name || "file");
|
||||||
|
const dest = path.join(uploadsDir(), stored);
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
fs.writeFileSync(dest, buf);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
url: `/uploads/${stored}`,
|
||||||
|
filename: file.name || stored,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -227,6 +227,54 @@ img {
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* per-post downloads section */
|
||||||
|
.rb-downloads {
|
||||||
|
margin-top: 26px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.rb-downloads-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.rb-downloads-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.rb-download-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.rb-download-link:hover {
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
.rb-download-icon {
|
||||||
|
font-size: 1.2em;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.rb-download-label {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rb-download-size {
|
||||||
|
flex: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
/* per-post share footer */
|
/* per-post share footer */
|
||||||
.rb-article-share {
|
.rb-article-share {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getPostBySlug,
|
getPostBySlug,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatSize,
|
||||||
} from "@/lib/posts";
|
} from "@/lib/posts";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
@@ -48,6 +49,32 @@ export default async function PostPage({
|
|||||||
className="rb-prose"
|
className="rb-prose"
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
|
{post.downloads.length > 0 && (
|
||||||
|
<section className="rb-downloads">
|
||||||
|
<h2 className="rb-downloads-title">Downloads</h2>
|
||||||
|
<ul className="rb-downloads-list">
|
||||||
|
{post.downloads.map((d, i) => (
|
||||||
|
<li key={`${d.url}-${i}`} className="rb-download">
|
||||||
|
<a
|
||||||
|
className="rb-download-link"
|
||||||
|
href={d.url}
|
||||||
|
{...(d.kind === "file"
|
||||||
|
? { download: d.filename ?? "" }
|
||||||
|
: { target: "_blank", rel: "noreferrer" })}
|
||||||
|
>
|
||||||
|
<span className="rb-download-icon" aria-hidden>
|
||||||
|
{d.kind === "file" ? "📦" : "🔗"}
|
||||||
|
</span>
|
||||||
|
<span className="rb-download-label">{d.label}</span>
|
||||||
|
{d.size != null && (
|
||||||
|
<span className="rb-download-size">{formatSize(d.size)}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{post.links.length > 0 && (
|
{post.links.length > 0 && (
|
||||||
<footer className="rb-article-share">
|
<footer className="rb-article-share">
|
||||||
<span className="rb-article-share-label">Shared on:</span>
|
<span className="rb-article-share-label">Shared on:</span>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { isSafeStoredName, mimeForName, uploadsDir } from "@/lib/uploads";
|
||||||
|
|
||||||
|
// GET /uploads/[name] — streams an uploaded attachment back to visitors. Public
|
||||||
|
// by design (downloads are meant to be downloaded); the name is validated so a
|
||||||
|
// crafted path can't escape the uploads directory.
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ name: string }> }
|
||||||
|
) {
|
||||||
|
const { name } = await params;
|
||||||
|
|
||||||
|
if (!isSafeStoredName(name)) {
|
||||||
|
return NextResponse.json({ error: "bad name" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = path.join(uploadsDir(), name);
|
||||||
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||||
|
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = fs.readFileSync(file);
|
||||||
|
return new NextResponse(new Uint8Array(data), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": mimeForName(name),
|
||||||
|
"Content-Length": String(data.length),
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+7
-3
@@ -61,12 +61,16 @@ function migrate(db: Database.Database) {
|
|||||||
|
|
||||||
// Per-post social-share links (JSON array). Added via ALTER so existing
|
// Per-post social-share links (JSON array). Added via ALTER so existing
|
||||||
// databases pick it up; guarded because SQLite has no ADD COLUMN IF NOT EXISTS.
|
// databases pick it up; guarded because SQLite has no ADD COLUMN IF NOT EXISTS.
|
||||||
const hasLinks = (
|
const cols = (
|
||||||
db.prepare("PRAGMA table_info(posts)").all() as { name: string }[]
|
db.prepare("PRAGMA table_info(posts)").all() as { name: string }[]
|
||||||
).some((c) => c.name === "links");
|
).map((c) => c.name);
|
||||||
if (!hasLinks) {
|
if (!cols.includes("links")) {
|
||||||
db.exec("ALTER TABLE posts ADD COLUMN links TEXT NOT NULL DEFAULT '[]'");
|
db.exec("ALTER TABLE posts ADD COLUMN links TEXT NOT NULL DEFAULT '[]'");
|
||||||
}
|
}
|
||||||
|
// Per-post download section (JSON array of {label,url,kind,size?,filename?}).
|
||||||
|
if (!cols.includes("downloads")) {
|
||||||
|
db.exec("ALTER TABLE posts ADD COLUMN downloads TEXT NOT NULL DEFAULT '[]'");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seed(db: Database.Database) {
|
function seed(db: Database.Database) {
|
||||||
|
|||||||
+68
-3
@@ -4,6 +4,17 @@ import { getDb } from "./db";
|
|||||||
import { isSocialNetworkId } from "./integrations/social";
|
import { isSocialNetworkId } from "./integrations/social";
|
||||||
import type { SocialLink } from "./integrations/types";
|
import type { SocialLink } from "./integrations/types";
|
||||||
|
|
||||||
|
/** A downloadable attached to a post: an uploaded file or an external link. */
|
||||||
|
export type Download = {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
kind: "file" | "link";
|
||||||
|
/** Byte size of an uploaded file, when known. */
|
||||||
|
size?: number;
|
||||||
|
/** Original filename of an uploaded file, for the download attribute. */
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Post = {
|
export type Post = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -14,6 +25,8 @@ export type Post = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
/** Links to where this post was shared on social networks. */
|
/** Links to where this post was shared on social networks. */
|
||||||
links: SocialLink[];
|
links: SocialLink[];
|
||||||
|
/** Files / external links offered for download on the post. */
|
||||||
|
downloads: Download[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +39,7 @@ type Row = {
|
|||||||
author: string;
|
author: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
links: string | null;
|
links: string | null;
|
||||||
|
downloads: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,6 +70,33 @@ function parseLinks(raw: string | null): SocialLink[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse the per-post downloads JSON, discarding malformed entries so a bad row
|
||||||
|
// can never break rendering.
|
||||||
|
function parseDownloads(raw: string | null): Download[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr
|
||||||
|
.map((o): Download | null => {
|
||||||
|
const rec = o as Record<string, unknown>;
|
||||||
|
const label = typeof rec.label === "string" ? rec.label.trim() : "";
|
||||||
|
const url = typeof rec.url === "string" ? rec.url.trim() : "";
|
||||||
|
if (!url) return null;
|
||||||
|
const kind = rec.kind === "file" ? "file" : "link";
|
||||||
|
const size = typeof rec.size === "number" && rec.size >= 0 ? rec.size : undefined;
|
||||||
|
const filename =
|
||||||
|
typeof rec.filename === "string" && rec.filename.trim()
|
||||||
|
? rec.filename.trim()
|
||||||
|
: undefined;
|
||||||
|
return { label: label || filename || url, url, kind, size, filename };
|
||||||
|
})
|
||||||
|
.filter((d): d is Download => d !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toPost(r: Row): Post {
|
function toPost(r: Row): Post {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -66,6 +107,7 @@ function toPost(r: Row): Post {
|
|||||||
author: r.author,
|
author: r.author,
|
||||||
tags: r.tags ? r.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
tags: r.tags ? r.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
||||||
links: parseLinks(r.links),
|
links: parseLinks(r.links),
|
||||||
|
downloads: parseDownloads(r.downloads),
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,6 +142,8 @@ export type PostInput = {
|
|||||||
tags?: string; // comma-separated
|
tags?: string; // comma-separated
|
||||||
/** Social links, one per line as `network|url` or `network|url|label`. */
|
/** Social links, one per line as `network|url` or `network|url|label`. */
|
||||||
links?: string;
|
links?: string;
|
||||||
|
/** Downloads as a JSON array string (from the download editor). */
|
||||||
|
downloads?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,6 +161,13 @@ function serializeLinks(input: string | undefined): string {
|
|||||||
return JSON.stringify(out);
|
return JSON.stringify(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-validate the editor's downloads JSON before storing it. Reuses
|
||||||
|
// parseDownloads so stored data matches what rendering will accept.
|
||||||
|
function serializeDownloads(input: string | undefined): string {
|
||||||
|
if (!input) return "[]";
|
||||||
|
return JSON.stringify(parseDownloads(input));
|
||||||
|
}
|
||||||
|
|
||||||
/** Render a post's links back to the editable `network|url|label` text form. */
|
/** Render a post's links back to the editable `network|url|label` text form. */
|
||||||
export function linksToText(links: SocialLink[]): string {
|
export function linksToText(links: SocialLink[]): string {
|
||||||
return links
|
return links
|
||||||
@@ -152,8 +203,8 @@ export function createPost(input: PostInput): Post {
|
|||||||
const slug = uniqueSlug(slugify(input.slug?.trim() || input.title));
|
const slug = uniqueSlug(slugify(input.slug?.trim() || input.title));
|
||||||
const info = getDb()
|
const info = getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO posts (slug, title, excerpt, body, author, tags, links, created_at)
|
`INSERT INTO posts (slug, title, excerpt, body, author, tags, links, downloads, created_at)
|
||||||
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @links, @created_at)`
|
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @links, @downloads, @created_at)`
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
slug,
|
slug,
|
||||||
@@ -163,6 +214,7 @@ export function createPost(input: PostInput): Post {
|
|||||||
author: input.author?.trim() || "webmaster",
|
author: input.author?.trim() || "webmaster",
|
||||||
tags: normalizeTags(input.tags),
|
tags: normalizeTags(input.tags),
|
||||||
links: serializeLinks(input.links),
|
links: serializeLinks(input.links),
|
||||||
|
downloads: serializeDownloads(input.downloads),
|
||||||
created_at: input.createdAt?.trim() || nowStamp(),
|
created_at: input.createdAt?.trim() || nowStamp(),
|
||||||
});
|
});
|
||||||
return getPostById(Number(info.lastInsertRowid))!;
|
return getPostById(Number(info.lastInsertRowid))!;
|
||||||
@@ -180,7 +232,7 @@ export function updatePost(id: number, input: PostInput): Post | null {
|
|||||||
`UPDATE posts
|
`UPDATE posts
|
||||||
SET slug = @slug, title = @title, excerpt = @excerpt,
|
SET slug = @slug, title = @title, excerpt = @excerpt,
|
||||||
body = @body, author = @author, tags = @tags,
|
body = @body, author = @author, tags = @tags,
|
||||||
links = @links, created_at = @created_at
|
links = @links, downloads = @downloads, created_at = @created_at
|
||||||
WHERE id = @id`
|
WHERE id = @id`
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
@@ -192,6 +244,7 @@ export function updatePost(id: number, input: PostInput): Post | null {
|
|||||||
author: input.author?.trim() || "webmaster",
|
author: input.author?.trim() || "webmaster",
|
||||||
tags: normalizeTags(input.tags),
|
tags: normalizeTags(input.tags),
|
||||||
links: serializeLinks(input.links),
|
links: serializeLinks(input.links),
|
||||||
|
downloads: serializeDownloads(input.downloads),
|
||||||
created_at: input.createdAt?.trim() || existing.createdAt,
|
created_at: input.createdAt?.trim() || existing.createdAt,
|
||||||
});
|
});
|
||||||
return getPostById(id);
|
return getPostById(id);
|
||||||
@@ -229,6 +282,18 @@ export function renderMarkdown(md: string): string {
|
|||||||
return marked.parse(md, { async: false }) as string;
|
return marked.parse(md, { async: false }) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable byte size, e.g. 1536 -> "1.5 KB". */
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let n = bytes;
|
||||||
|
let u = 0;
|
||||||
|
while (n >= 1024 && u < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
u++;
|
||||||
|
}
|
||||||
|
return `${n >= 10 || u === 0 ? Math.round(n) : n.toFixed(1)} ${units[u]}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(iso: string): string {
|
export function formatDate(iso: string): string {
|
||||||
const d = new Date(iso.replace(" ", "T"));
|
const d = new Date(iso.replace(" ", "T"));
|
||||||
if (isNaN(d.getTime())) return iso;
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import "server-only";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
// Uploaded post attachments live alongside the SQLite db under data/, so a
|
||||||
|
// single persisted volume covers both content and files. They are served back
|
||||||
|
// to visitors through the /uploads/[name] route (not Next's static public dir).
|
||||||
|
|
||||||
|
export function uploadsDir(): string {
|
||||||
|
const dir = path.join(process.cwd(), "data", "uploads");
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored filename is the safe, unique name we wrote to disk. Reject anything
|
||||||
|
// with path separators or traversal so the serve route can't escape the dir.
|
||||||
|
export function isSafeStoredName(name: string): boolean {
|
||||||
|
return /^[A-Za-z0-9._-]+$/.test(name) && !name.includes("..");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse an arbitrary client filename to a safe slug while keeping a sane
|
||||||
|
// extension, then prefix a short random token so uploads never clash.
|
||||||
|
export function makeStoredName(original: string): string {
|
||||||
|
const ext = path.extname(original).toLowerCase().replace(/[^.a-z0-9]/g, "").slice(0, 12);
|
||||||
|
const base =
|
||||||
|
path
|
||||||
|
.basename(original, path.extname(original))
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60) || "file";
|
||||||
|
const token = Math.random().toString(36).slice(2, 8) + Date.now().toString(36);
|
||||||
|
return `${token}-${base}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".zip": "application/zip",
|
||||||
|
".7z": "application/x-7z-compressed",
|
||||||
|
".rar": "application/vnd.rar",
|
||||||
|
".gz": "application/gzip",
|
||||||
|
".tar": "application/x-tar",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".md": "text/markdown; charset=utf-8",
|
||||||
|
".json": "application/json",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".iso": "application/x-iso9660-image",
|
||||||
|
".nes": "application/octet-stream",
|
||||||
|
".sfc": "application/octet-stream",
|
||||||
|
".smc": "application/octet-stream",
|
||||||
|
".gb": "application/octet-stream",
|
||||||
|
".gba": "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mimeForName(name: string): string {
|
||||||
|
return MIME_BY_EXT[path.extname(name).toLowerCase()] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user