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);