Skip to content
Merged
3 changes: 2 additions & 1 deletion apps/build-docs/src/build-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ const stubImageProvider: ImageProvider = {
},
compressImage: async () => ({ compressed: false, reason: "unsupported-platform" }),
planCompression: async () => ({ skip: "unsupported-platform" as const, decodeCost: null }),
compressionConcurrency: () => 1
compressionConcurrency: () => 1,
resizeForPreview: async () => ({ resized: false, reason: "unsupported-platform" as const })
};
import { ZipArchive } from "archiver";
import { execSync } from "child_process";
Expand Down
2 changes: 0 additions & 2 deletions apps/server/src/routes/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ 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";
Expand Down Expand Up @@ -185,7 +184,6 @@ function register(app: express.Application) {
// POST rather than GET: the URL would otherwise sit in the query string of every access-log
// line (Trilium's own, and any reverse proxy in front of it), and a pasted URL can carry a
// one-time token or a signed signature. The body is not logged.
asyncApiRoute(PST, "/api/link-embed/metadata", linkEmbedRoute.getMetadata);

asyncApiRoute(PST, "/api/onenote-import/device-login", onenoteImportRoute.deviceLogin);
asyncApiRoute(PST, "/api/onenote-import/device-poll", onenoteImportRoute.devicePoll);
Expand Down
48 changes: 47 additions & 1 deletion apps/server/src/services/image_codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import { IMAGE_COMPRESSIBLE_FORMATS, type ImageCompressionSkipReason } from "@triliumnext/commons";
import { type InspectedImage, inspectImage } from "@triliumnext/core/src/services/image_inspect.js";
import type { ImageCompressionOutcome, ImageCompressionRequest, ImageFormat } from "@triliumnext/core/src/services/image_provider.js";
import type { ImageCompressionOutcome, ImageCompressionRequest, ImageFormat, PreviewResizeOutcome, PreviewResizeRequest } from "@triliumnext/core/src/services/image_provider.js";
import { estimateJpegQuality } from "@triliumnext/core/src/services/jpeg_quality.js";
import imageType from "image-type";
import isAnimated from "is-animated";
Expand Down Expand Up @@ -442,3 +442,49 @@ export async function compressImageBytes(
return { compressed: true, buffer: result, format: toJpeg ? JPEG_FORMAT : PNG_FORMAT };
}

/**
* Scales a link preview's cover image down to `maxEdge` and re-encodes it.
*
* The bytes make a round trip — downloaded and stored in the same breath — so the reduction happens
* here rather than being left to the generic compression pass: a 5MB `og:image` has no business
* being carried through a pipeline sized for the user's own photographs to become a thumbnail
* nobody will see above a couple of hundred pixels.
*
* Transparency survives by re-encoding to PNG only where the picture actually has non-opaque pixels;
* an opaque one becomes a JPEG, which is several times smaller.
*
* Answers `undecodable` rather than throwing when Jimp cannot read the bytes — it bundles decoders
* for PNG/JPEG/GIF/BMP/TIFF only, so a WebP or an AVIF lands there, as does an error page served
* where a picture should have been. Saying so is enough; what a preview does without its picture is
* not this function's business.
*/
export async function resizePreviewImage(
bytes: Uint8Array,
{ maxEdge, jpegQuality }: PreviewResizeRequest,
log: CodecLog = () => {}
): Promise<PreviewResizeOutcome> {
try {
const image = await decodeImage(bytes);

// Only ever down: scaleToFit() would happily enlarge a smaller picture.
if (image.bitmap.width > maxEdge || image.bitmap.height > maxEdge) {
image.scaleToFit({ w: maxEdge, h: maxEdge });
}

// hasAlpha() inspects the pixels rather than just the channel, so an opaque PNG still takes
// the JPEG path. An animated GIF or WebP collapses to its first frame, which is all a
// thumbnail wanted of it.
const encoded = image.hasAlpha()
? await image.getBuffer("image/png")
: await image.getBuffer("image/jpeg", { quality: jpegQuality });

return { resized: true, bytes: new Uint8Array(encoded) };
} catch (e: unknown) {
// The address is deliberately left out of the line: it is the user's private browsing, and a
// pasted link can carry a one-time token in its path or query.
log(`Could not decode a link preview image: ${e}`, true);

return { resized: false, reason: "undecodable" };
}
}

75 changes: 75 additions & 0 deletions apps/server/src/services/image_codec_preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Jimp } from "jimp";
import { describe, expect, it } from "vitest";

import { resizePreviewImage } from "./image_codec.js";

const REQUEST = { maxEdge: 256, jpegQuality: 75 };

async function png(width: number, height: number, color = 0xff0000ff) {
return new Uint8Array(await new Jimp({ width, height, color }).getBuffer("image/png"));
}

async function decoded(bytes: Uint8Array) {
return await Jimp.fromBuffer(Buffer.from(bytes));
}

describe("resizePreviewImage", () => {
it("scales a large picture down to the longest edge asked for, keeping its proportions", async () => {
const result = await resizePreviewImage(await png(1000, 500), REQUEST);

expect(result.resized).toBe(true);
if (!result.resized) return;

const image = await decoded(result.bytes);
expect(image.bitmap.width).toBe(256);
expect(image.bitmap.height).toBe(128);
});

it("leaves a picture already smaller than the ceiling at the size it came", async () => {
const result = await resizePreviewImage(await png(64, 48), REQUEST);

expect(result.resized).toBe(true);
if (!result.resized) return;

const image = await decoded(result.bytes);
expect(image.bitmap.width).toBe(64);
expect(image.bitmap.height).toBe(48);
});

/**
* The choice of encoding is by what the pixels actually are, not by what the format allows: a
* PNG with nothing transparent in it is several times smaller as a JPEG, and a card thumbnail
* is not where that saving should be passed up.
*/
it("re-encodes an opaque picture to JPEG and a transparent one to PNG", async () => {
const opaque = await resizePreviewImage(await png(300, 300, 0x336699ff), REQUEST);
const transparent = await resizePreviewImage(await png(300, 300, 0x33669900), REQUEST);

expect(opaque.resized && (await decoded(opaque.bytes)).mime).toBe("image/jpeg");
expect(transparent.resized && (await decoded(transparent.bytes)).mime).toBe("image/png");
});

it("says so rather than throwing when the bytes are not a picture it can read", async () => {
// An error page served where a picture should have been — the case this has to survive.
const html = new TextEncoder().encode("<!doctype html><title>404</title>");

await expect(resizePreviewImage(html, REQUEST)).resolves.toEqual({
resized: false,
reason: "undecodable"
});
});

it("passes the reason to the log it was given, and manages without one", async () => {
const lines: string[] = [];
await resizePreviewImage(new Uint8Array([ 1, 2, 3 ]), REQUEST, (message) => lines.push(message));

expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/Could not decode a link preview image/);
// The address never appears: a pasted link can carry a one-time token in its query.
expect(lines[0]).not.toMatch(/http/);

await expect(resizePreviewImage(new Uint8Array([ 1, 2, 3 ]), REQUEST)).resolves.toMatchObject({
resized: false
});
});
});
14 changes: 3 additions & 11 deletions apps/server/src/services/image_compression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,10 +533,7 @@ describe("compression parameters", () => {
// The header pass is an optimization of the read, never a precondition for it — so when it
// cannot answer, the run must go the long way round rather than report a failure.
initImageProvider({
getImageType: (buffer) => real.getImageType(buffer),
processImage: (buffer, name, shrink) => real.processImage(buffer, name, shrink),
compressImage: (buffer, compressionRequest) => real.compressImage(buffer, compressionRequest),
compressionConcurrency: () => real.compressionConcurrency(),
...real,
planCompression: () => Promise.reject(new Error("planner unavailable"))
});

Expand Down Expand Up @@ -567,9 +564,7 @@ describe("compression parameters", () => {
let compressedSoFar = 0;

initImageProvider({
getImageType: (buffer) => real.getImageType(buffer),
processImage: (buffer, name, shrink) => real.processImage(buffer, name, shrink),
planCompression: (header, req) => real.planCompression(header, req),
...real,
compressionConcurrency: () => 1,
compressImage: async (buffer, req) => {
const outcome = await real.compressImage(buffer, req);
Expand Down Expand Up @@ -898,10 +893,7 @@ async function whileCompressing<T>(interfere: () => void, request: () => Promise
const real = getImageProvider();

initImageProvider({
getImageType: (buffer) => real.getImageType(buffer),
processImage: (buffer, originalName, shrink) => real.processImage(buffer, originalName, shrink),
planCompression: (header, compressionRequest) => real.planCompression(header, compressionRequest),
compressionConcurrency: () => real.compressionConcurrency(),
...real,
compressImage: (buffer, compressionRequest) => {
interfere();
return real.compressImage(buffer, compressionRequest);
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/services/image_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { getLog, imageCompressionService, options as optionService } from "@triliumnext/core";
import type { ImageCompressionOutcome, ImageCompressionPlan, ImageCompressionRequest, ImageFormat, ImageProvider, ProcessedImage } from "@triliumnext/core/src/services/image_provider.js";
import type { ImageCompressionOutcome, ImageCompressionPlan, ImageCompressionRequest, ImageFormat, ImageProvider, PreviewResizeOutcome, PreviewResizeRequest, ProcessedImage } from "@triliumnext/core/src/services/image_provider.js";

import { createConcurrencyGate } from "./concurrency_gate.js";
import {
Expand All @@ -16,7 +16,8 @@ import {
decodeCostOf,
detectSvg,
getImageTypeFromBuffer,
planFromBytes
planFromBytes,
resizePreviewImage
} from "./image_codec.js";
import { compressInWorker, compressionConcurrency } from "./image_worker_pool.js";

Expand Down Expand Up @@ -124,5 +125,9 @@ export const serverImageProvider: ImageProvider = {
return offThread ?? compressImageBytes(buffer, request, toBackendLog);
},

compressionConcurrency
compressionConcurrency,

async resizeForPreview(bytes: Uint8Array, request: PreviewResizeRequest): Promise<PreviewResizeOutcome> {
return await resizePreviewImage(bytes, request, toBackendLog);
}
};
19 changes: 17 additions & 2 deletions apps/server/src/services/request.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use strict";

import { type CookieJar, type ExecOpts, getLog, type RequestProvider, sync_options as syncOptions } from "@triliumnext/core";
import { type CookieJar, type ExecOpts, type FetchedResource, type FetchResourceOpts, getLog, type RequestProvider, sync_options as syncOptions } from "@triliumnext/core";
import { readCappedResponse } from "@triliumnext/core/src/services/request.js";
import url from "url";

import { createPinnedLookup, validateHostResolution, validateUrl } from "./safe_fetch.js";
import { createPinnedLookup, safeFetch, validateHostResolution, validateUrl } from "./safe_fetch.js";

// this service provides abstraction over node's HTTP/HTTPS modules.
// Subclasses (e.g. apps/desktop's ElectronRequestProvider) can override
Expand Down Expand Up @@ -193,6 +194,20 @@ export default class NodeRequestProvider implements RequestProvider {
});
}

/**
* Fetches a third-party resource, hardened the same way {@link getImage} is: the address is
* vetted, the name resolved, the private ranges refused, and the connection pinned to the
* addresses that were actually checked, so a second lookup cannot answer differently.
*
* This is the runtime where all of that is possible, and where it is most needed — the network
* a server can see is not one the author of a note is entitled to reach through it.
*/
async fetchResource(resourceUrl: string, opts: FetchResourceOpts): Promise<FetchedResource> {
const response = await safeFetch(resourceUrl, { headers: opts.headers });

return await readCappedResponse(response, opts.maxBytes);
}

/**
* Fetches an image named by note content.
*
Expand Down
27 changes: 6 additions & 21 deletions apps/server/src/services/safe_fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import dns from "node:dns";
import net from "node:net";

import { ValidationError } from "@triliumnext/core";
import { validateFetchableUrl } from "@triliumnext/core/src/services/request.js";
import ipaddr from "ipaddr.js";
import { Agent, fetch as undiciFetch, type RequestInit as UndiciRequestInit, type Response as UndiciResponse } from "undici";

Expand Down Expand Up @@ -61,27 +62,11 @@ async function validateHostResolution(hostname: string): Promise<dns.LookupAddre
return addresses;
}

function validateUrl(urlString: string): URL {
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
throw new ValidationError("Invalid URL");
}

if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new ValidationError("Only http and https URLs are supported");
}

// `https://user:pass@host/` would put the credentials on the wire, and — for a link preview —
// into the note's stored URL and the server log along with them. Nothing here needs to
// authenticate, so refuse the URL rather than carry a secret around.
if (parsed.username || parsed.password) {
throw new ValidationError("URLs containing credentials are not supported");
}

return parsed;
}
/**
* The address checks, which are core's — they are about the URL rather than about the network, so
* every runtime makes them and only this one can follow them with a resolution.
*/
const validateUrl = validateFetchableUrl;

/**
* Creates a custom DNS lookup function that only returns pre-validated IP addresses,
Expand Down
56 changes: 56 additions & 0 deletions apps/standalone/src/lightweight/bridged_request_provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,59 @@ describe("BridgedRequestProvider.getImage", () => {
await expect(promise).rejects.toThrow("404 GET");
});
});

describe("BridgedRequestProvider.fetchResource", () => {
it("asks the native side for the bytes and reports what they came under", async () => {
const provider = new BridgedRequestProvider();
const promise = provider.fetchResource("https://example.com/p", {
maxBytes: 1000,
headers: { "user-agent": "TriliumNotes" }
});

expect(lastPosted().request).toMatchObject({
method: "GET",
url: "https://example.com/p",
headers: { "user-agent": "TriliumNotes" },
responseType: "arraybuffer"
});

respond({ status: 200, headers: { "content-type": "text/html; charset=utf-8" }, body: btoa("<html/>") });

await expect(promise).resolves.toEqual({
status: 200,
ok: true,
contentType: "text/html",
bytes: new TextEncoder().encode("<html/>")
});
});

it("answers a non-2xx rather than throwing, the page having said something", async () => {
const provider = new BridgedRequestProvider();
const promise = provider.fetchResource("https://example.com/gone", { maxBytes: 1000 });

respond({ status: 404, headers: {}, body: btoa("nope") });

await expect(promise).resolves.toMatchObject({ status: 404, ok: false, contentType: "" });
});

it("refuses an address before anything is posted for it", async () => {
const provider = new BridgedRequestProvider();
const posted = postSpy.mock.calls.length;

await expect(provider.fetchResource("file:///etc/passwd", { maxBytes: 10 })).rejects.toThrow(/http and https/);
expect(postSpy.mock.calls.length).toBe(posted);
});

/**
* The ceiling is checked on what arrived rather than on what is arriving — a native transport
* hands over a whole response, so there is no stream to abandon. It still has to hold.
*/
it("refuses a body over the ceiling once it is back", async () => {
const provider = new BridgedRequestProvider();
const promise = provider.fetchResource("https://example.com/big", { maxBytes: 10 });

respond({ status: 200, headers: {}, body: btoa("x".repeat(50)) });

await expect(promise).rejects.toThrow(/exceeds the 10 byte limit/);
});
});
Loading
Loading