diff --git a/apps/client/src/assets/manifest.webmanifest b/apps/client/src/assets/manifest.webmanifest
index 1f8cb3b69a9..016b82c1053 100644
--- a/apps/client/src/assets/manifest.webmanifest
+++ b/apps/client/src/assets/manifest.webmanifest
@@ -16,5 +16,21 @@
"sizes": "512x512",
"type": "image/png"
}
- ]
+ ],
+ "share_target": {
+ "action": "/share-target",
+ "method": "POST",
+ "enctype": "multipart/form-data",
+ "params": {
+ "title": "title",
+ "text": "text",
+ "url": "url",
+ "files": [
+ {
+ "name": "files",
+ "accept": ["*/*"]
+ }
+ ]
+ }
+ }
}
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 3074e6ed010..b9e28d44b29 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -12,7 +12,7 @@ import path from "path";
import favicon from "serve-favicon";
import type serveStatic from "serve-static";
-import assets from "./routes/assets.js";
+import assets, { getClientDir } from "./routes/assets.js";
import custom from "./routes/custom.js";
import error_handlers from "./routes/error_handlers.js";
import mcpRoutes from "./routes/mcp.js";
@@ -30,7 +30,11 @@ export default async function buildApp() {
const app = express();
const publicDir = isDev ? path.join(getResourceDir(), "../dist/public") : path.join(getResourceDir(), "public");
- const publicAssetsDir = path.join(publicDir, "assets");
+ // Static client assets (manifest, icons, robots). getClientDir() resolves to the
+ // client source dir in dev and the built public dir in prod; both keep these under
+ // `assets/`. Using it (instead of publicDir) lets the PWA manifest resolve in dev,
+ // where dist/public is not built.
+ const publicAssetsDir = path.join(getClientDir(), "assets");
const assetsDir = RESOURCE_DIR;
// view engine setup
diff --git a/apps/server/src/routes/api/share_target.spec.ts b/apps/server/src/routes/api/share_target.spec.ts
new file mode 100644
index 00000000000..987ea1e7bf8
--- /dev/null
+++ b/apps/server/src/routes/api/share_target.spec.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+
+import { buildBody, deriveTitle, getOptionalString, needsContainerNote } from "./share_target.js";
+
+describe("getOptionalString", () => {
+ it("returns the value for non-blank strings", () => {
+ expect(getOptionalString("hello")).toBe("hello");
+ expect(getOptionalString(" padded ")).toBe(" padded ");
+ });
+
+ it("returns undefined for blanks and non-strings", () => {
+ expect(getOptionalString("")).toBeUndefined();
+ expect(getOptionalString(" ")).toBeUndefined();
+ expect(getOptionalString(undefined)).toBeUndefined();
+ expect(getOptionalString(null)).toBeUndefined();
+ expect(getOptionalString(42)).toBeUndefined();
+ });
+});
+
+describe("deriveTitle", () => {
+ it("prefers the explicit title, trimmed", () => {
+ expect(deriveTitle(" My Title ", "body")).toBe("My Title");
+ });
+
+ it("falls back to the first line of the body", () => {
+ expect(deriveTitle(undefined, "first line\nsecond line")).toBe("first line");
+ expect(deriveTitle(" ", "first line\r\nsecond")).toBe("first line");
+ });
+
+ it("falls back to a placeholder when nothing usable is provided", () => {
+ expect(deriveTitle(undefined, undefined)).toBe("Shared content");
+ expect(deriveTitle(undefined, " ")).toBe("Shared content");
+ expect(deriveTitle(42, undefined)).toBe("Shared content");
+ });
+
+ it("truncates long titles to 80 characters", () => {
+ const long = "a".repeat(200);
+ expect(deriveTitle(long, undefined)).toHaveLength(80);
+ expect(deriveTitle(undefined, long)).toHaveLength(80);
+ });
+});
+
+describe("buildBody", () => {
+ it("escapes HTML in shared text and converts newlines to
", () => {
+ expect(buildBody("hello world\nsecond", undefined)).toBe(
+ "
hello <b>world</b>
second
"
+ );
+ });
+
+ it("renders a shared url as an escaped anchor", () => {
+ expect(buildBody(undefined, "https://example.com/a?x=1&y=2")).toBe(
+ `https://example.com/a?x=1&y=2
`
+ );
+ });
+
+ it("includes both text and url when both are present", () => {
+ expect(buildBody("note", "https://example.com")).toBe(
+ `note
https://example.com
`
+ );
+ });
+
+ it("returns an empty string when nothing is provided", () => {
+ expect(buildBody(undefined, undefined)).toBe("");
+ });
+});
+
+describe("needsContainerNote", () => {
+ it("groups under a parent when text/url is present", () => {
+ expect(needsContainerNote(true, 0)).toBe(true);
+ expect(needsContainerNote(true, 1)).toBe(true);
+ expect(needsContainerNote(true, 5)).toBe(true);
+ });
+
+ it("groups under a parent when more than one file is shared", () => {
+ expect(needsContainerNote(false, 2)).toBe(true);
+ expect(needsContainerNote(false, 10)).toBe(true);
+ });
+
+ it("does not create a parent for a single lone file", () => {
+ expect(needsContainerNote(false, 1)).toBe(false);
+ });
+
+ it("does not create a parent when nothing is shared", () => {
+ expect(needsContainerNote(false, 0)).toBe(false);
+ });
+});
diff --git a/apps/server/src/routes/api/share_target.ts b/apps/server/src/routes/api/share_target.ts
new file mode 100644
index 00000000000..b474dd55f89
--- /dev/null
+++ b/apps/server/src/routes/api/share_target.ts
@@ -0,0 +1,127 @@
+import { date_utils as dateUtils, sanitize, special_notes as specialNotesService, utils } from "@triliumnext/core";
+import type { Request, Response } from "express";
+
+import imageService from "../../services/image.js";
+import noteService from "../../services/notes.js";
+
+const SAFE_TITLE_MAX = 80;
+
+/**
+ * Picks a note title from the shared `title` field, falling back to the first
+ * line of the shared body, then to a generic placeholder.
+ */
+export function deriveTitle(rawTitle: unknown, fallbackBody: string | undefined): string {
+ if (typeof rawTitle === "string" && rawTitle.trim()) {
+ return rawTitle.trim().slice(0, SAFE_TITLE_MAX);
+ }
+ if (fallbackBody) {
+ const firstLine = fallbackBody.trim().split(/\r?\n/, 1)[0] ?? "";
+ if (firstLine) {
+ return firstLine.slice(0, SAFE_TITLE_MAX);
+ }
+ }
+ return "Shared content";
+}
+
+/** Builds the HTML body for a text/url share, escaping user-provided content. */
+export function buildBody(text: string | undefined, url: string | undefined): string {
+ const parts: string[] = [];
+ if (text) {
+ parts.push(`${utils.escapeHtml(text).replace(/\r?\n/g, "
")}
`);
+ }
+ if (url) {
+ const escapedUrl = utils.escapeHtml(url);
+ parts.push(`${escapedUrl}
`);
+ }
+ return parts.join("");
+}
+
+/** Returns the value if it's a non-blank string, otherwise undefined. */
+export function getOptionalString(value: unknown): string | undefined {
+ if (typeof value === "string" && value.trim()) {
+ return value;
+ }
+ return undefined;
+}
+
+/**
+ * Whether shared items should be grouped under a parent note. We create a
+ * parent when there's accompanying text/url, or when more than one file is
+ * shared; a single lone file is placed directly in the inbox instead.
+ */
+export function needsContainerNote(hasText: boolean, fileCount: number): boolean {
+ return hasText || fileCount > 1;
+}
+
+async function handleShare(req: Request, res: Response) {
+ const inboxNote = specialNotesService.getInboxNote(dateUtils.localNowDate());
+
+ const rawTitle = req.body?.title;
+ const text = getOptionalString(req.body?.text);
+ const rawUrl = getOptionalString(req.body?.url);
+ const url = rawUrl ? sanitize.sanitizeUrl(rawUrl) : undefined;
+ const files = (req.files as Express.Multer.File[] | undefined) ?? [];
+
+ const hasText = !!(text || url);
+ if (!hasText && files.length === 0) {
+ res.status(400).type("text/plain").send("Nothing to share: no text, url or files were provided.");
+ return;
+ }
+
+ // When there's text/url or more than one file, create a parent note that
+ // holds the text/url and groups the files as children. A single lone file
+ // is placed directly in the inbox.
+ let containerNoteId: string | null = null;
+ if (needsContainerNote(hasText, files.length)) {
+ const { note } = noteService.createNewNote({
+ parentNoteId: inboxNote.noteId,
+ title: deriveTitle(rawTitle, text || url),
+ content: buildBody(text, url) || "",
+ type: "text",
+ mime: "text/html",
+ isProtected: false
+ });
+ note.addLabel("sharedToTrilium");
+ if (url) {
+ note.setLabel("pageUrl", url);
+ }
+ containerNoteId = note.noteId;
+ }
+
+ const fileParentNoteId = containerNoteId ?? inboxNote.noteId;
+ let firstFileNoteId: string | null = null;
+
+ for (const file of files) {
+ if (file.mimetype.startsWith("image/")) {
+ const { note, noteId } = imageService.saveImage(
+ fileParentNoteId,
+ file.buffer,
+ file.originalname || "image",
+ true // shrinkImageSwitch: optimize shared images, matching the Sender/clipper behaviour
+ );
+ note.addLabel("sharedToTrilium");
+ firstFileNoteId ??= noteId;
+ continue;
+ }
+
+ const title = deriveTitle(file.originalname, undefined);
+ const { note } = noteService.createNewNote({
+ parentNoteId: fileParentNoteId,
+ title,
+ content: file.buffer,
+ type: "file",
+ mime: file.mimetype || "application/octet-stream",
+ isProtected: false
+ });
+ note.addLabel("originalFileName", file.originalname || title);
+ note.addLabel("sharedToTrilium");
+ firstFileNoteId ??= note.noteId;
+ }
+
+ // Open the parent note when one was created, otherwise the lone file note.
+ res.redirect(303, `/#root/${containerNoteId ?? firstFileNoteId}`);
+}
+
+export default {
+ handleShare
+};
diff --git a/apps/server/src/routes/assets.ts b/apps/server/src/routes/assets.ts
index 7a7b06f164b..fe5837408ce 100644
--- a/apps/server/src/routes/assets.ts
+++ b/apps/server/src/routes/assets.ts
@@ -35,6 +35,12 @@ async function register(app: express.Application) {
if (process.env.NODE_ENV === "development") {
const { createServer: createViteServer } = await import("vite");
const clientDir = path.join(srcRoot, "../client");
+ // Vite 5+ rejects any Host other than localhost. Allow extra hosts
+ // (reverse-proxy / LAN dev access) via comma-separated env var.
+ const extraAllowedHosts = (process.env.TRILIUM_DEV_ALLOWED_HOSTS ?? "")
+ .split(",")
+ .map((h) => h.trim())
+ .filter(Boolean);
const vite = await createViteServer({
server: {
middlewareMode: true,
@@ -43,7 +49,8 @@ async function register(app: express.Application) {
// multiple dev instances (e.g. server on 8080, desktop on
// 37742) don't all fight over Vite's default port 24678.
port: port + 10
- }
+ },
+ ...(extraAllowedHosts.length > 0 && { allowedHosts: extraAllowedHosts })
},
appType: "spa",
configFile: path.join(clientDir, "vite.config.mts"),
diff --git a/apps/server/src/routes/route_api.ts b/apps/server/src/routes/route_api.ts
index eec463c6e9c..ec4d018a8fd 100644
--- a/apps/server/src/routes/route_api.ts
+++ b/apps/server/src/routes/route_api.ts
@@ -143,7 +143,7 @@ function handleException(e: unknown | Error, method: HttpMethod, path: string, r
}
-export function createUploadMiddleware(): RequestHandler {
+function buildMulter(): multer.Multer {
const multerOptions: multer.Options = {
fileFilter: (req: express.Request, file, cb) => {
// UTF-8 file names are not well decoded by multer/busboy, so we handle the conversion on our side.
@@ -159,17 +159,31 @@ export function createUploadMiddleware(): RequestHandler {
};
}
- return multer(multerOptions).single("upload");
+ return multer(multerOptions);
}
-const uploadMiddleware = createUploadMiddleware();
+export function createUploadMiddleware(): RequestHandler {
+ return buildMulter().single("upload");
+}
-export const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) {
- uploadMiddleware(req, res, (err) => {
- if (err?.code === "LIMIT_FILE_SIZE") {
- res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
- } else {
- next();
- }
- });
-};
+export function createMultiUploadMiddleware(fieldName: string): RequestHandler {
+ return buildMulter().array(fieldName);
+}
+
+function wrapWithSizeError(middleware: RequestHandler): RequestHandler {
+ return (req, res, next) => {
+ middleware(req, res, (err) => {
+ if (err?.code === "LIMIT_FILE_SIZE") {
+ res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it exceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
+ } else {
+ next();
+ }
+ });
+ };
+}
+
+export const uploadMiddlewareWithErrorHandling = wrapWithSizeError(createUploadMiddleware());
+
+export function createMultiUploadMiddlewareWithErrorHandling(fieldName: string): RequestHandler {
+ return wrapWithSizeError(createMultiUploadMiddleware(fieldName));
+}
diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts
index 8558ff84f36..c17a05a1642 100644
--- a/apps/server/src/routes/routes.ts
+++ b/apps/server/src/routes/routes.ts
@@ -23,6 +23,8 @@ import databaseRoute from "./api/database.js";
import etapiTokensApiRoutes from "./api/etapi_tokens.js";
import filesRoute from "./api/files.js";
import fontsRoute from "./api/fonts.js";
+// API routes
+import linkEmbedRoute from "./api/link_embed.js";
import llmChatRoute from "./api/llm_chat.js";
import llmSpecialNotesRoute from "./api/llm_special_notes.js";
import loginApiRoute from "./api/login.js";
@@ -30,14 +32,13 @@ import metricsRoute from "./api/metrics.js";
import ocrRoute from "./api/ocr.js";
import recoveryCodes from './api/recovery_codes.js';
import senderRoute from "./api/sender.js";
+import shareTargetRoute from "./api/share_target.js";
import systemInfoRoute from "./api/system_info.js";
import totp from './api/totp.js';
-// API routes
-import linkEmbedRoute from "./api/link_embed.js";
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
import * as indexRoute from "./index.js";
import loginRoute from "./login.js";
-import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
+import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, createMultiUploadMiddlewareWithErrorHandling, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
// page routes
import setupRoute from "./setup.js";
@@ -176,6 +177,16 @@ function register(app: express.Application) {
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);
asyncRoute(PST, "/api/sender/note", [auth.checkEtapiToken], senderRoute.saveNote, apiResultHandler);
+ // PWA Web Share Target. No CSRF token possible (the browser's share UI posts this form),
+ // but session auth + SameSite=Lax cookies prevent cross-origin POSTs from carrying credentials.
+ asyncRoute(
+ PST,
+ "/share-target",
+ [auth.checkAuth, createMultiUploadMiddlewareWithErrorHandling("files")],
+ shareTargetRoute.handleShare,
+ null
+ );
+
route(GET, "/api/fonts", [auth.checkApiAuthOrElectron], fontsRoute.getFontCss);
asyncApiRoute(GET, "/api/link-embed/metadata", linkEmbedRoute.getMetadata);