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
18 changes: 17 additions & 1 deletion apps/client/src/assets/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -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": ["*/*"]
}
]
}
}
}
8 changes: 6 additions & 2 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions apps/server/src/routes/api/share_target.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <br>", () => {
expect(buildBody("hello <b>world</b>\nsecond", undefined)).toBe(
"<p>hello &lt;b&gt;world&lt;/b&gt;<br>second</p>"
);
});

it("renders a shared url as an escaped anchor", () => {
expect(buildBody(undefined, "https://example.com/a?x=1&y=2")).toBe(
`<p><a href="https://example.com/a?x=1&amp;y=2">https://example.com/a?x=1&amp;y=2</a></p>`
);
});

it("includes both text and url when both are present", () => {
expect(buildBody("note", "https://example.com")).toBe(
`<p>note</p><p><a href="https://example.com">https://example.com</a></p>`
);
});

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);
});
});
127 changes: 127 additions & 0 deletions apps/server/src/routes/api/share_target.ts
Original file line number Diff line number Diff line change
@@ -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(`<p>${utils.escapeHtml(text).replace(/\r?\n/g, "<br>")}</p>`);
}
if (url) {
const escapedUrl = utils.escapeHtml(url);
parts.push(`<p><a href="${escapedUrl}">${escapedUrl}</a></p>`);
}
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) || "<p></p>",
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
};
9 changes: 8 additions & 1 deletion apps/server/src/routes/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand Down
38 changes: 26 additions & 12 deletions apps/server/src/routes/route_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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));
}
17 changes: 14 additions & 3 deletions apps/server/src/routes/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,22 @@ 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";
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";

Expand Down Expand Up @@ -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);

Expand Down
Loading