Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Release

on:
push:
tags:
- "v*"
workflow_dispatch:

jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Type-check
run: bun run check

- name: Build and package
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Force dist | Out-Null

$exe = if ($env:RUNNER_OS -eq "Windows") { "moltbook-client.exe" } else { "moltbook-client" }
bun build src/index.ts --compile --outfile "dist/$exe"

Copy-Item -Recurse -Force src/assets dist/assets

$zip = "moltbook-client-$($env:RUNNER_OS)-$($env:RUNNER_ARCH).zip"
if (Test-Path $zip) { Remove-Item -Force $zip }
Compress-Archive -Path dist/* -DestinationPath $zip
"ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append

- uses: actions/upload-artifact@v4
with:
name: moltbook-client-${{ runner.os }}-${{ runner.arch }}
path: ${{ env.ZIP_PATH }}

release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts

- name: List artifacts
run: ls -R artifacts

- uses: softprops/action-gh-release@v2
with:
files: artifacts/**/*.zip

34 changes: 34 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
"check": "tsc --noEmit"
},
"dependencies": {
"marked": "^17.0.1"
"marked": "^17.0.1",
"sanitize-html": "^2.13.1"
},
"devDependencies": {
"@types/bun": "latest",
"@types/sanitize-html": "^2.11.0",
"typescript": "^5.5"
}
}
1 change: 1 addition & 0 deletions src/assets/vendor/htmx.min.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/assets/vendor/pico.min.css

Large diffs are not rendered by default.

101 changes: 90 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,138 @@
import { join, extname, dirname } from "path";
import { existsSync } from "fs";
import { esc, layout } from "./templates/layout";
import { handleAuth } from "./routes/auth";
import { handleFeed } from "./routes/feed";
import { handlePosts } from "./routes/posts";
import { handleComments } from "./routes/comments";
import { handleSubmolts } from "./routes/submolts";
import { handleProfile } from "./routes/profile";
import { handleAuth } from "./routes/auth";
import { handleMessages } from "./routes/messages";
import { handleModeration } from "./routes/moderation";
import { handleSearch } from "./routes/search";
import { handleMoltys } from "./routes/moltys";
import { layout } from "./templates/layout";

const PORT = parseInt(process.env.PORT ?? "3000", 10);
const HOST = process.env.HOST ?? "127.0.0.1";
const DEV_ASSETS_DIR = join(import.meta.dir, "assets");
const PROD_ASSETS_DIR = join(dirname(process.execPath), "assets");
const ASSETS_DIR = existsSync(PROD_ASSETS_DIR) ? PROD_ASSETS_DIR : DEV_ASSETS_DIR;

// Route handlers in priority order
const handlers = [
handleAuth,
handleFeed,
handleSearch,
handleMoltys,
handleComments, // before posts so /posts/:id/comments matches first
handleComments, // before posts so /posts/:id/comments matches first
handlePosts,
handleSubmolts,
handleModeration, // /s/:name/mod/* before submolt detail catch-all
handleProfile,
handleMessages,
];

function contentTypeForPath(p: string): string {
switch (extname(p).toLowerCase()) {
case ".css": return "text/css; charset=utf-8";
case ".js": return "text/javascript; charset=utf-8";
case ".svg": return "image/svg+xml";
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".webp": return "image/webp";
case ".ico": return "image/x-icon";
default: return "application/octet-stream";
}
}

function withSecurityHeaders(res: Response): Response {
const headers = new Headers(res.headers);

// Safer defaults for a local web UI that may hold credentials.
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "no-referrer");
headers.set("X-Frame-Options", "DENY");
headers.set("Cross-Origin-Opener-Policy", "same-origin");
headers.set("Cross-Origin-Resource-Policy", "same-origin");
headers.set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()");

// No remote scripts/styles; keep inline styles for now (templates use many style="" attrs).
headers.set(
"Content-Security-Policy",
[
"default-src 'self'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self'",
].join("; ")
);

const ct = headers.get("Content-Type") ?? "";
if (ct.startsWith("text/html")) {
headers.set("Cache-Control", "no-store");
}

return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}

Bun.serve({
hostname: HOST,
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;

// Static assets (vendored dependencies, icons, etc.)
if ((req.method === "GET" || req.method === "HEAD") && path.startsWith("/assets/")) {
const rel = path.slice("/assets/".length);
// Prevent path traversal and weird separators.
if (!rel || rel.includes("..") || rel.includes("\\") || rel.startsWith("/")) {
return withSecurityHeaders(new Response("Bad Request", { status: 400 }));
}
const fsPath = join(ASSETS_DIR, rel);
const file = Bun.file(fsPath);
if (!(await file.exists())) {
return withSecurityHeaders(new Response("Not Found", { status: 404 }));
}
const headers = new Headers({
"Content-Type": contentTypeForPath(fsPath),
"Cache-Control": "public, max-age=31536000, immutable",
});
return withSecurityHeaders(new Response(file, { headers }));
}

// Try each route handler
for (const handler of handlers) {
try {
const response = await handler(req, path);
if (response) return response;
if (response) return withSecurityHeaders(response);
} catch (e: any) {
console.error(`Handler error: ${e.message}`);
const body = `<h2>Error</h2><p>${e.message ?? "Something went wrong"}</p>`;
return new Response(layout("Error", body), {
const body = `<h2>Error</h2><p>${esc(e.message ?? "Something went wrong")}</p>`;
return withSecurityHeaders(new Response(layout("Error", body), {
status: 500,
headers: { "Content-Type": "text/html" },
});
}));
}
}

// 404
const body = `<h2>404 Not Found</h2><p>The page <code>${path}</code> doesn't exist.</p><p><a href="/">Go home</a></p>`;
return new Response(layout("Not Found", body), {
const body = `<h2>404 - Not Found</h2><p>The page <code>${esc(path)}</code> doesn't exist.</p><p><a href="/">Go home</a></p>`;
return withSecurityHeaders(new Response(layout("Not Found", body), {
status: 404,
headers: { "Content-Type": "text/html" },
});
}));
},
});

console.log(`Moltbook Client listening on http://localhost:${PORT}`);
console.log(`Moltbook Client listening on http://${HOST}:${PORT}`);
50 changes: 50 additions & 0 deletions src/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";

// Treat all markdown as untrusted. Convert to HTML, then sanitize to prevent XSS.

const ALLOWED_TAGS: sanitizeHtml.IOptions["allowedTags"] = [
"p", "br",
"strong", "em", "del",
"code", "pre",
"blockquote",
"ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6",
"hr",
"a",
];

const ALLOWED_ATTRS: sanitizeHtml.IOptions["allowedAttributes"] = {
a: ["href", "title", "rel", "target"],
code: ["class"],
};

export function renderMarkdown(md: unknown): string {
const src = typeof md === "string" ? md : "";
const raw = marked.parse(src) as string;

return sanitizeHtml(raw, {
allowedTags: ALLOWED_TAGS,
allowedAttributes: ALLOWED_ATTRS,
// Drop images by default (privacy/tracking).
allowedSchemes: ["http", "https", "mailto"],
allowProtocolRelative: false,
disallowedTagsMode: "discard",
transformTags: {
a: (tagName, attribs) => {
const href = attribs.href ?? "";
// Prevent target=_blank tabnabbing and avoid leaking referrers.
const rel = "noopener noreferrer nofollow";
const target = href.startsWith("/") ? undefined : "_blank";
return {
tagName,
attribs: {
...attribs,
rel,
...(target ? { target } : {}),
},
};
},
},
});
}
13 changes: 0 additions & 13 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,6 @@ import { layout, partial, loadingPlaceholder } from "../templates/layout";
import { settingsPage, diagnosticRowResult, getChecksForUser } from "../templates/settings";
import { setConfig, deleteConfig, getConfig, logAction } from "../db";
import * as api from "../api";
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
import { join } from "path";
import { homedir } from "os";

const CREDS_DIR = join(homedir(), ".config", "moltbook");
const CREDS_FILE = join(CREDS_DIR, "credentials.json");

function saveCredsToDisk(agentName: string, apiKey: string, claimUrl?: string) {
mkdirSync(CREDS_DIR, { recursive: true });
writeFileSync(CREDS_FILE, JSON.stringify({ agent_name: agentName, api_key: apiKey, claim_url: claimUrl }, null, 2));
}

function isHtmx(req: Request): boolean {
return req.headers.get("HX-Request") === "true";
Expand Down Expand Up @@ -102,7 +91,6 @@ export async function handleAuth(req: Request, path: string): Promise<Response |
if (agent.api_key) setConfig("api_key", agent.api_key);
if (agent.claim_url) setConfig("claim_url", agent.claim_url);
if (agent.verification_code) setConfig("verification_code", agent.verification_code);
saveCredsToDisk(agentName, agent.api_key, agent.claim_url);
logAction("register", agentName);
return Response.redirect("/settings", 303);
} catch (e: any) {
Expand All @@ -121,7 +109,6 @@ export async function handleAuth(req: Request, path: string): Promise<Response |
}
setConfig("agent_name", agentName);
setConfig("api_key", apiKey);
saveCredsToDisk(agentName, apiKey);
logAction("import_key", agentName);
return Response.redirect("/settings", 303);
} catch (e: any) {
Expand Down
2 changes: 1 addition & 1 deletion src/routes/moltys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function handleMoltys(req: Request, path: string): Promise<Response
// GET /agents/search?q=... — typeahead for agent names
if (path === "/agents/search" && req.method === "GET") {
const url = new URL(req.url);
const q = (url.searchParams.get("q") ?? "").toLowerCase();
const q = (url.searchParams.get("q") ?? url.searchParams.get("agent") ?? "").toLowerCase();
if (q.length < 1) {
return new Response("", { headers: { "Content-Type": "text/html" } });
}
Expand Down
2 changes: 1 addition & 1 deletion src/routes/submolts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function handleSubmolts(req: Request, path: string): Promise<Respon

// GET /submolts/search?q=... — typeahead for submolt names
if (path === "/submolts/search" && req.method === "GET") {
const q = (url.searchParams.get("q") ?? "").toLowerCase();
const q = (url.searchParams.get("q") ?? url.searchParams.get("submolt") ?? "").toLowerCase();
if (q.length < 1) {
return new Response("", { headers: { "Content-Type": "text/html" } });
}
Expand Down
Loading