import safeCompare from "safe-compare"; import type { Request, Response, Router } from "express"; import shaca from "./shaca/shaca.js"; import shacaLoader from "./shaca/shaca_loader.js"; import searchService from "../services/search/services/search.js"; import SearchContext from "../services/search/search_context.js"; import type SNote from "./shaca/entities/snote.js"; import type SAttachment from "./shaca/entities/sattachment.js"; import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js"; import utils from "../services/utils.js"; function addNoIndexHeader(note: SNote, res: Response) { if (note.isLabelTruthy("shareDisallowRobotIndexing")) { res.setHeader("X-Robots-Tag", "noindex"); } } function requestCredentials(res: Response) { res.setHeader("WWW-Authenticate", 'Basic realm="User Visible Realm", charset="UTF-8"').sendStatus(401); } function checkAttachmentAccess(attachmentId: string, req: Request, res: Response) { const attachment = shaca.getAttachment(attachmentId); if (!attachment) { res.status(404).json({ message: `Attachment '${attachmentId}' not found.` }); return false; } const note = checkNoteAccess(attachment.ownerId, req, res); // truthy note means the user has access, and we can return the attachment return note ? attachment : false; } function checkNoteAccess(noteId: string, req: Request, res: Response) { const note = shaca.getNote(noteId); if (!note) { res.status(404).json({ message: `Note '${noteId}' not found.` }); return false; } if (noteId === "_share" && !shaca.shareIndexEnabled) { res.status(403).json({ message: `Accessing share index is forbidden.` }); return false; } const credentials = note.getCredentials(); if (credentials.length === 0) { return note; } const header = req.header("Authorization"); if (!header?.startsWith("Basic ")) { if (req.path.startsWith("/share/api") && note.contentAccessor) { let contentAccessToken = "" if (note.contentAccessor.type === "cookie") contentAccessToken += req.cookies["trilium.cat"] || "" else if (note.contentAccessor.type === "query") contentAccessToken += req.query['cat'] || "" if (contentAccessToken){ if (note.contentAccessor.isTokenValid(contentAccessToken)){ return note } res.status(401).send("Access is expired. Return back and update the page."); return false; } } return false; } const base64Str = header.substring("Basic ".length); const buffer = Buffer.from(base64Str, "base64"); const authString = buffer.toString("utf-8"); for (const credentialLabel of credentials) { if (safeCompare(authString, credentialLabel.value)) { return note; // success; } } return false; } function renderImageAttachment(image: SNote, res: Response, attachmentName: string) { let svgString = ""; const attachment = image.getAttachmentByTitle(attachmentName); if (!attachment) { return; } const content = attachment.getContent(); if (typeof content === "string") { svgString = content; } else { // backwards compatibility, before attachments, the SVG was stored in the main note content as a separate key const possibleSvgContent = image.getJsonContentSafely(); const contentSvg = (typeof possibleSvgContent === "object" && possibleSvgContent !== null && "svg" in possibleSvgContent && typeof possibleSvgContent.svg === "string") ? possibleSvgContent.svg : null; if (contentSvg) { svgString = contentSvg; } } const svg = svgString; res.set("Content-Type", "image/svg+xml"); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); res.send(svg); } function render404(res: Response) { res.status(404); const shareThemePath = getDefaultTemplatePath("404"); res.render(shareThemePath); } function register(router: Router) { function renderNote(note: SNote, req: Request, res: Response) { if (!note) { console.log("Unable to find note ", note); res.status(404); render404(res); return; } if (note.isLabelTruthy("shareExclude")) { res.status(404); render404(res); return; } if (!checkNoteAccess(note.noteId, req, res)) { requestCredentials(res); return; } addNoIndexHeader(note, res); if (note.isLabelTruthy("shareRaw") || typeof req.query.raw !== "undefined") { res.setHeader("Content-Type", note.mime).send(note.getContent()); return; } if (note.contentAccessor && note.contentAccessor.type === "cookie") { res.cookie('trilium.cat', note.contentAccessor.getToken(), { maxAge: note.contentAccessor.getTokenExpiration() * 1000, httpOnly: true }) } res.send(renderNoteContent(note)); } router.get("/share/", (req, res) => { if (req.path.substr(-1) !== "/") { res.redirect("../share/"); return; } shacaLoader.ensureLoad(); if (!shaca.shareRootNote) { res.status(404).json({ message: "Share root note not found" }); return; } renderNote(shaca.shareRootNote, req, res); }); router.get("/share/:shareId", (req, res) => { shacaLoader.ensureLoad(); const { shareId } = req.params; const note = shaca.aliasToNote[shareId] || shaca.notes[shareId]; if (note){ note.initContentAccessor() } renderNote(note, req, res); }); router.get("/share/api/notes/:noteId", (req, res) => { shacaLoader.ensureLoad(); let note: SNote | boolean; if (!(note = checkNoteAccess(req.params.noteId, req, res))) { return; } addNoIndexHeader(note, res); res.json(note.getPojo()); }); router.get("/share/api/notes/:noteId/download", (req, res) => { shacaLoader.ensureLoad(); let note: SNote | boolean; if (!(note = checkNoteAccess(req.params.noteId, req, res))) { return; } addNoIndexHeader(note, res); const filename = utils.formatDownloadTitle(note.title, note.type, note.mime); res.setHeader("Content-Disposition", utils.getContentDisposition(filename)); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Content-Type", note.mime); res.send(note.getContent()); }); // :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename router.get("/share/api/images/:noteId/:filename", (req, res) => { shacaLoader.ensureLoad(); let image: SNote | boolean; if (!(image = checkNoteAccess(req.params.noteId, req, res))) { return; } if (image.type === "image") { // normal image res.set("Content-Type", image.mime); addNoIndexHeader(image, res); res.send(image.getContent()); } else if (image.type === "canvas") { renderImageAttachment(image, res, "canvas-export.svg"); } else if (image.type === "mermaid") { renderImageAttachment(image, res, "mermaid-export.svg"); } else if (image.type === "mindMap") { renderImageAttachment(image, res, "mindmap-export.svg"); } else { res.status(400).json({ message: "Requested note is not a shareable image" }); } }); // :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename router.get("/share/api/attachments/:attachmentId/image/:filename", (req, res) => { shacaLoader.ensureLoad(); let attachment: SAttachment | boolean; if (!(attachment = checkAttachmentAccess(req.params.attachmentId, req, res))) { return; } if (attachment.role === "image") { res.set("Content-Type", attachment.mime); addNoIndexHeader(attachment.note, res); res.send(attachment.getContent()); } else { res.status(400).json({ message: "Requested attachment is not a shareable image" }); } }); router.get("/share/api/attachments/:attachmentId/download", (req, res) => { shacaLoader.ensureLoad(); let attachment: SAttachment | boolean; if (!(attachment = checkAttachmentAccess(req.params.attachmentId, req, res))) { return; } addNoIndexHeader(attachment.note, res); const filename = utils.formatDownloadTitle(attachment.title, null, attachment.mime); res.setHeader("Content-Disposition", utils.getContentDisposition(filename)); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Content-Type", attachment.mime); res.send(attachment.getContent()); }); // used for PDF viewing router.get("/share/api/notes/:noteId/view", (req, res) => { shacaLoader.ensureLoad(); let note: SNote | boolean; if (!(note = checkNoteAccess(req.params.noteId, req, res))) { return; } addNoIndexHeader(note, res); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Content-Type", note.mime); res.send(note.getContent()); }); // Used for searching, require noteId so we know the subTreeRoot router.get("/share/api/notes", (req, res) => { shacaLoader.ensureLoad(); const ancestorNoteId = req.query.ancestorNoteId ?? "_share"; if (typeof ancestorNoteId !== "string") { res.status(400).json({ message: "'ancestorNoteId' parameter is mandatory." }); return; } // This will automatically return if no ancestorNoteId is provided and there is no shareIndex if (!checkNoteAccess(ancestorNoteId, req, res)) { return; } const { search } = req.query; if (typeof search !== "string" || !search?.trim()) { res.status(400).json({ message: "'search' parameter is mandatory." }); return; } const searchContext = new SearchContext({ ancestorNoteId: ancestorNoteId }); const searchResults = searchService.findResultsWithQuery(search, searchContext); const filteredResults = searchResults.map((sr) => { const fullNote = shaca.notes[sr.noteId]; const startIndex = sr.notePathArray.indexOf(ancestorNoteId); const localPathArray = sr.notePathArray.slice(startIndex + 1).filter((id) => shaca.notes[id]); const pathTitle = localPathArray.map((id) => shaca.notes[id].title).join(" / "); return { id: fullNote.shareId, title: fullNote.title, score: sr.score, path: pathTitle }; }); res.json({ results: filteredResults }); }); } export default { register };