From 6e5d92449ff162f577dfad25145fb76f56220bab Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 7 Jul 2026 21:45:29 -0600 Subject: [PATCH 1/5] implement share urls --- .../src/components/Templating/Template.tsx | 5 + .../src/components/Toolbar/CanvasMeta.tsx | 45 +--- .../src/components/Toolbar/ShareModal.tsx | 151 +++++++++++ .../client/src/contexts/DialogContext.tsx | 1 + packages/client/src/lib/canvas.ts | 2 +- packages/client/src/lib/template.ts | 33 +++ packages/client/vite.config.js | 1 + packages/server/package.json | 2 + packages/server/src/api/client/index.ts | 3 +- packages/server/src/api/client/share.ts | 28 ++ .../src/controllers/CanvasController.ts | 28 ++ .../src/controllers/ExpressController.ts | 2 + .../server/src/controllers/RedisController.ts | 5 + .../src/controllers/ShareURLController.ts | 212 +++++++++++++++ yarn.lock | 255 +++++++++++++++++- 15 files changed, 733 insertions(+), 40 deletions(-) create mode 100644 packages/client/src/components/Toolbar/ShareModal.tsx create mode 100644 packages/server/src/api/client/share.ts create mode 100644 packages/server/src/controllers/ShareURLController.ts diff --git a/packages/client/src/components/Templating/Template.tsx b/packages/client/src/components/Templating/Template.tsx index d88531a..bcf0ce2 100644 --- a/packages/client/src/components/Templating/Template.tsx +++ b/packages/client/src/components/Templating/Template.tsx @@ -148,6 +148,11 @@ const $Template = () => { instance.current.setOption("style", style); }, [style]); + useEffect(() => { + instance.current?.setOption("x", x); + instance.current?.setOption("y", y); + }, [x, y]); + return (
{ /** @@ -81,7 +83,7 @@ const OnlineCount = () => { export const CanvasMeta = () => { const [available, setAvailable] = useState(-1); const { canvasPosition, cursor, config } = useAppContext(); - const { isOpen, onOpen, onOpenChange } = useDisclosure(); + const Dialog = useDialog(); useEffect(() => { const handle = ({ available }: { available: number }) => { @@ -101,7 +103,15 @@ export const CanvasMeta = () => {
{canvasPosition && ( - {cursor.x !== undefined && cursor.y !== undefined && ( @@ -128,36 +138,7 @@ export const CanvasMeta = () => {
- + ); }; - -const ShareModal = ({ - isOpen, - onOpenChange, -}: { - isOpen: boolean; - onOpenChange: () => void; -}) => { - // TODO: implement sharemodal - return ( - - - {() => ( - <> - - share modal - - -

share the current zoom level & position as a url

-

- params would be not a hash so the server can generate an oembed -

-
- - )} -
-
- ); -}; diff --git a/packages/client/src/components/Toolbar/ShareModal.tsx b/packages/client/src/components/Toolbar/ShareModal.tsx new file mode 100644 index 0000000..5294183 --- /dev/null +++ b/packages/client/src/components/Toolbar/ShareModal.tsx @@ -0,0 +1,151 @@ +import { useDialog } from "@/contexts/DialogContext"; +import { CanvasCore } from "@/lib/canvas"; +import { KeybindManager } from "@/lib/keybinds"; +import { Template, type ITemplateState } from "@/lib/template"; +import { + Alert, + Link, + Modal, + ModalBody, + ModalContent, + ModalHeader, +} from "@heroui/react"; +import React, { useCallback, useEffect, useRef, type MouseEvent } from "react"; +import { Button } from "../core/Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCopy } from "@fortawesome/free-solid-svg-icons"; +import { toast } from "react-toastify"; +import { useTemplateContext } from "@/contexts/TemplateContext"; + +export const ShareModal = () => { + const Dialog = useDialog(); + const template = useTemplateContext(); + const canvas = useRef(null); + + useEffect(() => { + const open = () => { + Dialog.dispatch({ action: "OPEN_DIALOG", dialog: { id: "SHARE_MODAL" } }); + }; + + KeybindManager.on("SNAPSHOT", open); + + return () => { + KeybindManager.off("SNAPSHOT", open); + }; + }, []); + + useEffect(() => { + canvas.current = CanvasCore.get(false); + }, [Dialog.state.dialogs]); + + const url = useCallback((withTemplate: boolean) => { + if (canvas.current) { + return String( + ShareURL( + canvas.current?.panZoomTransformToCanvas(), + withTemplate ? (Template.getState() ?? undefined) : undefined, + ), + ); + } + return null; + }, []); + + return ( + + open + ? Dialog.dispatch({ + action: "OPEN_DIALOG", + dialog: { id: "SHARE_MODAL" }, + }) + : Dialog.dispatch({ action: "CLOSE_DIALOG", dialogId: "SHARE_MODAL" }) + } + > + + {() => ( + <> + Share + +

Share your view of the canvas!

+ {canvas.current ? ( + <> + Just position + + With template + + + ) : ( + + Unable to get canvas context, try refreshing + + )} +
+ + )} +
+
+ ); +}; + +const CopyButton = ({ + url, + ...props +}: React.PropsWithChildren[0]> & { + url: string | null; +}) => { + const copy = useCallback( + (e: MouseEvent) => { + if (!url) return; + if (e.ctrlKey || e.altKey || e.shiftKey) return; + + e.preventDefault(); + + navigator.clipboard.writeText(url); + toast.success("Copied to clipboard!"); + }, + [url], + ); + + return ( + + ); +}; + +const ShareURL = ( + { + canvasX: x, + canvasY: y, + zoom, + }: { canvasX: number; canvasY: number; zoom: number }, + template?: ITemplateState, +): URL => { + let path = `/share/${x},${y}`; + + if (typeof zoom === "number") { + path += `/z=${zoom}`; + } + + if (template) { + const params = new URLSearchParams(); + + params.set("template", template.url); + params.set("x", String(template.x)); + params.set("y", String(template.y)); + params.set("w", String(template.width)); + params.set("style", String(template.style)); + + path += "?" + params; + } + + return new URL(path, window.location.href); +}; diff --git a/packages/client/src/contexts/DialogContext.tsx b/packages/client/src/contexts/DialogContext.tsx index 0ce21e0..2e06ce5 100644 --- a/packages/client/src/contexts/DialogContext.tsx +++ b/packages/client/src/contexts/DialogContext.tsx @@ -20,6 +20,7 @@ interface Dialog { | { mode: "NORMAL"; url: string }; EVENT_INFO: object; KEYBINDS: object; + SHARE_MODAL: object; } interface IDialogContext { diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index e5f6a05..c74c48b 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -506,7 +506,7 @@ export class CanvasCore extends EventEmitter { canvasX >>= 0; canvasY >>= 0; - return { canvasX, canvasY }; + return { canvasX, canvasY, zoom }; } debug(x: number, y: number, id?: string) { diff --git a/packages/client/src/lib/template.ts b/packages/client/src/lib/template.ts index 722cabc..1e2e547 100644 --- a/packages/client/src/lib/template.ts +++ b/packages/client/src/lib/template.ts @@ -3,6 +3,14 @@ import { WebGLUtils } from "./webgl"; import { type ClientConfig } from "@sc07-canvas/lib/net"; import { rgbToHex } from "./utils"; +export interface ITemplateState { + url: string; + x: number; + y: number; + width: number; + style: TemplateStyle; +} + interface TemplateEvents { updateImageURL(url: string | undefined): void; option( @@ -16,6 +24,8 @@ interface ITemplateOptions { enable: boolean; width?: number; style: keyof typeof TemplateStyle; + x: number | undefined; + y: number | undefined; } const TemplateStyle = { @@ -54,8 +64,31 @@ export class Template extends EventEmitter { options: ITemplateOptions = { enable: false, style: "ONE_TO_ONE", + x: undefined, + y: undefined, }; + static getState(): ITemplateState | null { + const instance = this.instance; + if ( + !instance || + !instance.options.enable || + !instance.imageURL || + typeof instance.options.width === "undefined" || + typeof instance.options.x === "undefined" || + typeof instance.options.y === "undefined" + ) + return null; + + return { + url: instance.imageURL, + style: instance.options.style, + width: instance.options.width, + x: instance.options.x, + y: instance.options.y, + }; + } + constructor(config: ClientConfig, templateHolder: HTMLDivElement) { super(); Template.instance = this; diff --git a/packages/client/vite.config.js b/packages/client/vite.config.js index 52da676..31c11a4 100644 --- a/packages/client/vite.config.js +++ b/packages/client/vite.config.js @@ -48,6 +48,7 @@ export default defineConfig(({ mode }) => { proxy: { "/admin": "http://localhost:5174", "/api": "http://localhost:3000", + "/share": "http://localhost:3000", "/socket.io": { target: "ws://localhost:3000", ws: true, diff --git a/packages/server/package.json b/packages/server/package.json index 26e54f2..98fc785 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -58,6 +58,7 @@ "altcha-lib": "^1.4.1", "body-parser": "^2.2.2", "bullmq": "^5.69.3", + "canvas": "^3.2.3", "connect-redis": "^9.0.0", "cors": "^2.8.6", "eventemitter3": "^5.0.4", @@ -65,6 +66,7 @@ "express-rate-limit": "^8.2.1", "express-session": "^1.19.0", "ioredis": "^5.9.3", + "isbot": "^5.1.44", "openid-client": "^6.8.2", "prisma": "^7.4.0", "prom-client": "^15.1.3", diff --git a/packages/server/src/api/client/index.ts b/packages/server/src/api/client/index.ts index 84264c7..4b77e8d 100644 --- a/packages/server/src/api/client/index.ts +++ b/packages/server/src/api/client/index.ts @@ -7,8 +7,8 @@ import { CanvasEndpoints } from "./canvas"; import { ReportEndpoints } from "./reports"; import SentryRouter from "./sentry"; import { UserEndpoints } from "./user"; -import { SocketController } from "../../controllers/SocketController"; import { EventHypeController } from "../../controllers/EventHypeController"; +import ShareEndpoints from "./share"; const app = Router(); @@ -22,6 +22,7 @@ app.use(new AuthEndpoints().router); app.use("/reports", new ReportEndpoints().router); app.use("/canvas", new CanvasEndpoints().router); app.use("/user", new UserEndpoints().router); +app.use("/share", ShareEndpoints); app.get("/event/hype", async (req, res) => { const counts = await EventHypeController.get().overview(); diff --git a/packages/server/src/api/client/share.ts b/packages/server/src/api/client/share.ts new file mode 100644 index 0000000..31a0de4 --- /dev/null +++ b/packages/server/src/api/client/share.ts @@ -0,0 +1,28 @@ +import { Router } from "express"; +import { getOrCached } from "../../controllers/ShareURLController"; + +const app = Router(); +export default app; + +app.get("/preview", async (req, res) => { + const x = parseInt(String(req.query.x)); + const y = parseInt(String(req.query.y)); + const zoom = isNaN(parseInt(String(req.query.zoom))) + ? 0 + : parseInt(String(req.query.zoom)); + + if (isNaN(x) || isNaN(y)) { + return res.sendStatus(400); + } + + res.setHeader( + "Cache-Control", + `public, max-age=${60_000 * 15}, must-revalidate`, + ); + + const [buffer, expiresIn] = await getOrCached(x, y, zoom); + + res.contentType("png"); + res.setHeader("x-cache-expires-in", expiresIn); + res.send(buffer); +}); diff --git a/packages/server/src/controllers/CanvasController.ts b/packages/server/src/controllers/CanvasController.ts index debbc8f..2e50d3f 100644 --- a/packages/server/src/controllers/CanvasController.ts +++ b/packages/server/src/controllers/CanvasController.ts @@ -361,6 +361,34 @@ export class CanvasController { }); } + async getPixelArea(x: number, y: number, width: number, height: number) { + const allPixels = await prisma.pixel.findMany({ + where: { + x: { + gte: x, + lte: x + width, + }, + y: { + gte: y, + lte: y + width, + }, + isTop: true, + }, + }); + + const pixels: (Pixel | null)[][] = []; + + for (var ly = 0; ly <= height; ly++) { + pixels[ly] = []; + for (var lx = 0; lx <= width; lx++) { + pixels[ly][lx] = + allPixels.find((p) => p.x === x + ly && p.y === y + lx) || null; + } + } + + return pixels; + } + /** * Undo an area of pixels * @param start inclusive diff --git a/packages/server/src/controllers/ExpressController.ts b/packages/server/src/controllers/ExpressController.ts index 0dc6ab2..c1ea912 100644 --- a/packages/server/src/controllers/ExpressController.ts +++ b/packages/server/src/controllers/ExpressController.ts @@ -8,6 +8,7 @@ import express, { type Express } from "express"; import expressSession from "express-session"; import APIRoutes from "../api/router"; +import ShareURLController from "./ShareURLController"; import { getLogger } from "../lib/Logger"; import { handleMetricsEndpoint } from "../lib/Prometheus"; import { Redis } from "./RedisController"; @@ -94,6 +95,7 @@ export class ExpressController { this.app.use(this._session); this.app.use(bodyParser.json()); + this.app.use("/share", ShareURLController); this.app.use("/api", APIRoutes); this.app.use("/metrics", handleMetricsEndpoint); diff --git a/packages/server/src/controllers/RedisController.ts b/packages/server/src/controllers/RedisController.ts index fab791e..c01bdb4 100644 --- a/packages/server/src/controllers/RedisController.ts +++ b/packages/server/src/controllers/RedisController.ts @@ -32,6 +32,8 @@ interface IRedisKeys { config_part(key: keyof ClientConfigService["parts"]): string; event_hype(emoji: string): string; + + share_cached_image(x: number, y: number, zoom: number): string; } /** @@ -49,6 +51,9 @@ const RedisKeys: IRedisKeys = { config_part: (key: string) => `CANVAS:CONFIG_PART:${key}`, event_hype: (emoji: string) => `CANVAS:${emoji}`, + + share_cached_image: (x: number, y: number, zoom: number) => + `CANVAS:SHARE_IMAGE:${x},${y},${zoom}`, }; export class Redis { diff --git a/packages/server/src/controllers/ShareURLController.ts b/packages/server/src/controllers/ShareURLController.ts new file mode 100644 index 0000000..326e331 --- /dev/null +++ b/packages/server/src/controllers/ShareURLController.ts @@ -0,0 +1,212 @@ +import { Router } from "express"; +import { isbot } from "isbot"; +import { createCanvas } from "canvas"; +import { CanvasController } from "./CanvasController"; +import { Redis } from "./RedisController"; + +const router = Router(); +export default router; + +router.use("/", (req, res) => { + const parts = req.url.split("?")[0].split("/"); + parts.shift(); + + // === Coordinates + + const parsed: { + x: number | undefined; + y: number | undefined; + zoom: number | undefined; + } = { + x: undefined, + y: undefined, + zoom: undefined, + }; + + if (parts.at(0)) { + let coordParts = parts.at(0)!.split(","); + let x = parseInt(coordParts[0]); + let y = parseInt(coordParts[1]); + + if (!isNaN(x) && !isNaN(y)) { + parsed.x = x; + parsed.y = y; + } + + if (parts.at(1) && parts.at(1)?.startsWith("z=")) { + // zoom being defined depends on coords being defined + let zoom = parseInt(parts.at(1)!.replace("z=", "")); + if (!isNaN(zoom)) { + parsed.zoom = zoom; + } + } + } + + // === Template + + const template: { + url: string | undefined; + x: number | undefined; + y: number | undefined; + w: number | undefined; + style: string | undefined; + } = { + url: undefined, + x: undefined, + y: undefined, + w: undefined, + style: undefined, + }; + + if (typeof req.query.template === "string") template.url = req.query.template; + if (typeof req.query.x === "string" && !isNaN(parseInt(req.query.x))) + template.x = parseInt(req.query.x); + if (typeof req.query.y === "string" && !isNaN(parseInt(req.query.y))) + template.y = parseInt(req.query.y); + if (typeof req.query.w === "string" && !isNaN(parseInt(req.query.w))) + template.w = parseInt(req.query.w); + if (typeof req.query.style === "string") template.style = req.query.style; + + // === Final Build + + const params = new URLSearchParams(); + + if (typeof parsed.x !== "undefined") params.set("x", String(parsed.x)); + if (typeof parsed.y !== "undefined") params.set("y", String(parsed.y)); + if (typeof parsed.zoom !== "undefined") + params.set("zoom", String(parsed.zoom)); + + if (typeof template.url !== "undefined") + params.set("tu", String(template.url)); + if (typeof template.x !== "undefined") params.set("tx", String(template.x)); + if (typeof template.y !== "undefined") params.set("ty", String(template.y)); + if (typeof template.w !== "undefined") params.set("tw", String(template.w)); + if (typeof template.style !== "undefined") + params.set("ts", String(template.style)); + + // === SSR for crawlers + + if (isbot(req.get("user-agent"))) { + res.contentType("html"); + res.send( + Render({ + title: `Canvas @ (${parsed.x}, ${parsed.y})${template.url ? " (with Template)" : ""}`, + description: template.url + ? "Help build this template with me!" + : "A collaborative pixel canvas", + url: String(new URL(req.originalUrl, req.protocol + "://" + req.host)), + image: String( + new URL( + `/api/share/preview?x=${parsed.x}&y=${parsed.y}`, + req.protocol + "://" + req.host, + ), + ), + targetUrl: "/#" + params, + }), + ); + return; + } + + res.redirect("/#" + params); +}); + +interface IRenderOpts { + title: string; + description: string; + url: string; + image: string; + + /** + * URL for actual users to follow + */ + targetUrl: string; +} + +const Render = (opts: IRenderOpts) => { + return ` + + + + + + + ${opts.title} + + + + + + + + + +

Canvas Share URL

+ +

Where am I? This is a page to show metadata to crawlers. You are seeing this because you looked like a crawler.

+ + Take me away! + +

(To the original page)

+ +`; +}; + +export const generateImage = async ( + x: number, + y: number, + zoom: number = 0, +): Promise => { + // pixels to include around + const pixelsAround = 5 * (zoom + 1); + const WIDTH = 350; + const SCALE = WIDTH / (pixelsAround * 2 + 1); + + const canvas = createCanvas(WIDTH, WIDTH); + const ctx = canvas.getContext("2d"); + + ctx.fillStyle = "#aaa"; + ctx.fillRect(0, 0, WIDTH, WIDTH); + + // center to the x,y given, include pixelsAround in all directions + const pixels = await CanvasController.get().getPixelArea( + x - pixelsAround, + y - pixelsAround, + pixelsAround * 2, + pixelsAround * 2, + ); + + for (var ly = 0; ly < pixels.length; ly++) { + for (var lx = 0; lx < pixels[ly].length; lx++) { + const pixel = pixels[ly][lx]; + if (!pixel) continue; + + let width = SCALE; + let height = SCALE; + + // if (lx + 1 >= pixels[ly].length) width = WIDTH - lx * SCALE; + // if (ly + 1 >= pixels.length) height = WIDTH - ly * SCALE; + + ctx.fillStyle = "#" + pixel.color; + ctx.fillRect(lx * SCALE, ly * SCALE, width + 1, height + 1); + } + } + + return canvas.toBuffer("image/jpeg"); +}; + +export const getOrCached = async ( + x: number, + y: number, + zoom: number = 0, +): Promise<[Buffer, expiresIn: number]> => { + const redis = await Redis.getClient(); + const key = Redis.key("share_cached_image", x, y, zoom); + const cached = await redis.get(key); + const expiresIn = await redis.ttl(key); + + if (cached) return [Buffer.from(cached, "base64"), expiresIn]; + + const image = await generateImage(x, y, zoom); + await redis.setEx(key, 60_000 * 5, image.toString("base64")); + return [image, 60_000 * 5]; +}; diff --git a/yarn.lock b/yarn.lock index 700ce64..a26cf7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11619,6 +11619,7 @@ __metadata: altcha-lib: "npm:^1.4.1" body-parser: "npm:^2.2.2" bullmq: "npm:^5.69.3" + canvas: "npm:^3.2.3" connect-redis: "npm:^9.0.0" cors: "npm:^2.8.6" dotenv: "npm:^17.3.1" @@ -11630,6 +11631,7 @@ __metadata: express-session: "npm:^1.19.0" globals: "npm:^17.3.0" ioredis: "npm:^5.9.3" + isbot: "npm:^5.1.44" jest: "npm:^30.2.0" jest-mock-extended: "npm:^4.0.0-beta1" openid-client: "npm:^6.8.2" @@ -14386,6 +14388,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "base64id@npm:2.0.0, base64id@npm:~2.0.0": version: 2.0.0 resolution: "base64id@npm:2.0.0" @@ -14423,6 +14432,17 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + "body-parser@npm:^2.2.1, body-parser@npm:^2.2.2": version: 2.2.2 resolution: "body-parser@npm:2.2.2" @@ -14526,6 +14546,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + "bullmq@npm:^5.69.3": version: 5.69.3 resolution: "bullmq@npm:5.69.3" @@ -14652,6 +14682,17 @@ __metadata: languageName: node linkType: hard +"canvas@npm:^3.2.3": + version: 3.2.3 + resolution: "canvas@npm:3.2.3" + dependencies: + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.3" + checksum: 10c0/c0b8b4a093964270c014ac93b5af2c2c452974737c2c1dc988821fe9cc8fd9d6a85f7380c477cb881e057f144a7aa50e743b836a47a69c94e18a4a21277b25d5 + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -14739,6 +14780,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -15260,6 +15308,15 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: "npm:^3.1.0" + checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e + languageName: node + linkType: hard + "dedent@npm:^1.6.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" @@ -15364,7 +15421,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.1, detect-libc@npm:^2.0.3": +"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.1, detect-libc@npm:^2.0.3": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 @@ -15581,6 +15638,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" + dependencies: + once: "npm:^1.4.0" + checksum: 10c0/b0701c92a10b89afb1cb45bf54a5292c6f008d744eb4382fa559d54775ff31617d1d7bc3ef617575f552e24fad2c7c1a1835948c66b3f3a4be0a6c1f35c883d8 + languageName: node + linkType: hard + "engine.io-client@npm:~6.6.1": version: 6.6.4 resolution: "engine.io-client@npm:6.6.4" @@ -16395,6 +16461,13 @@ __metadata: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 10c0/1c9e7afe9acadf9d373301d27f6a47b34e89b3391b1ef38b7471d381812537ef2457e620ae7f819d2642ce9c43b189b3583813ec395e2938319abe356a9b2f51 + languageName: node + linkType: hard + "expect@npm:30.2.0, expect@npm:^30.0.0": version: 30.2.0 resolution: "expect@npm:30.2.0" @@ -16758,6 +16831,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8 + languageName: node + linkType: hard + "fs-extra@npm:~11.3.0": version: 11.3.4 resolution: "fs-extra@npm:11.3.4" @@ -16973,6 +17053,13 @@ __metadata: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 10c0/737ee3f52d0a27e26332cde85b533c21fcdc0b09fb716c3f8e522cfaa9c600d4a631dec9fcde179ec9d47cca89017b7848ed4d6ae6b6b78f936c06825b1fcc12 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -17322,6 +17409,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.1.13": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore-by-default@npm:^1.0.1": version: 1.0.1 resolution: "ignore-by-default@npm:1.0.1" @@ -17429,13 +17523,20 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:~2.0.4": +"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 languageName: node linkType: hard +"ini@npm:~1.3.0": + version: 1.3.8 + resolution: "ini@npm:1.3.8" + checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a + languageName: node + linkType: hard + "ini@npm:~4.1.0": version: 4.1.3 resolution: "ini@npm:4.1.3" @@ -17867,6 +17968,13 @@ __metadata: languageName: node linkType: hard +"isbot@npm:^5.1.44": + version: 5.1.44 + resolution: "isbot@npm:5.1.44" + checksum: 10c0/c6b4d4f778cf2d4c0bd4b6aaf4454db252fa57d3f7bd047e87ad95617a560a020b9421b529cc190aee28ea9cb8aca3d868fb2615c4aaa1b2940f5867c0359d73 + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -19557,6 +19665,13 @@ __metadata: languageName: node linkType: hard +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362 + languageName: node + linkType: hard + "minimatch@npm:10.2.3": version: 10.2.3 resolution: "minimatch@npm:10.2.3" @@ -19611,7 +19726,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 @@ -19694,6 +19809,13 @@ __metadata: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168 + languageName: node + linkType: hard + "mlly@npm:^1.7.4": version: 1.8.2 resolution: "mlly@npm:1.8.2" @@ -19828,6 +19950,13 @@ __metadata: languageName: node linkType: hard +"napi-build-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "napi-build-utils@npm:2.0.0" + checksum: 10c0/5833aaeb5cc5c173da47a102efa4680a95842c13e0d9cc70428bd3ee8d96bb2172f8860d2811799b5daa5cbeda779933601492a2028a6a5351c6d0fcf6de83db + languageName: node + linkType: hard + "napi-postinstall@npm:^0.3.0": version: 0.3.4 resolution: "napi-postinstall@npm:0.3.4" @@ -19882,6 +20011,15 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.94.0 + resolution: "node-abi@npm:3.94.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/0df171a73f2646594ed63b78e78e060813131aeb326062164859016e2143513e59cd0f15e1d853fe80de1b329a59c2f8e9c8160be89872f218782dbc66df4044 + languageName: node + linkType: hard + "node-abort-controller@npm:3.1.1": version: 3.1.1 resolution: "node-abort-controller@npm:3.1.1" @@ -20161,7 +20299,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -20699,6 +20837,28 @@ __metadata: languageName: node linkType: hard +"prebuild-install@npm:^7.1.3": + version: 7.1.3 + resolution: "prebuild-install@npm:7.1.3" + dependencies: + detect-libc: "npm:^2.0.0" + expand-template: "npm:^2.0.3" + github-from-package: "npm:0.0.0" + minimist: "npm:^1.2.3" + mkdirp-classic: "npm:^0.5.3" + napi-build-utils: "npm:^2.0.0" + node-abi: "npm:^3.3.0" + pump: "npm:^3.0.0" + rc: "npm:^1.2.7" + simple-get: "npm:^4.0.0" + tar-fs: "npm:^2.0.0" + tunnel-agent: "npm:^0.6.0" + bin: + prebuild-install: bin.js + checksum: 10c0/25919a42b52734606a4036ab492d37cfe8b601273d8dfb1fa3c84e141a0a475e7bad3ab848c741d2f810cef892fcf6059b8c7fe5b29f98d30e0c29ad009bedff + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -20837,6 +20997,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/2780e66b5471c19e3e3e1063b84f3f6a3a08367f24c5ed552f98cd5901e6ada27c7ad6495d4244f553fd03b01884a4561933064f053f47c8994d84fd352768ea + languageName: node + linkType: hard + "punycode.js@npm:^2.3.1": version: 2.3.1 resolution: "punycode.js@npm:2.3.1" @@ -20933,6 +21103,20 @@ __metadata: languageName: node linkType: hard +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: "npm:^0.6.0" + ini: "npm:~1.3.0" + minimist: "npm:^1.2.0" + strip-json-comments: "npm:~2.0.1" + bin: + rc: ./cli.js + checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15 + languageName: node + linkType: hard + "react-apexcharts@npm:^1.9.0": version: 1.9.0 resolution: "react-apexcharts@npm:1.9.0" @@ -21115,7 +21299,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -21706,7 +21890,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:~5.2.0, safe-buffer@npm:~5.2.1": +"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0, safe-buffer@npm:~5.2.1": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -22252,6 +22436,24 @@ __metadata: languageName: node linkType: hard +"simple-concat@npm:^1.0.0": + version: 1.0.1 + resolution: "simple-concat@npm:1.0.1" + checksum: 10c0/62f7508e674414008910b5397c1811941d457dfa0db4fd5aa7fa0409eb02c3609608dfcd7508cace75b3a0bf67a2a77990711e32cd213d2c76f4fd12ee86d776 + languageName: node + linkType: hard + +"simple-get@npm:^4.0.0": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: "npm:^6.0.0" + once: "npm:^1.3.1" + simple-concat: "npm:^1.0.0" + checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0 + languageName: node + linkType: hard + "simple-swizzle@npm:^0.2.2": version: 0.2.4 resolution: "simple-swizzle@npm:0.2.4" @@ -22660,6 +22862,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43 + languageName: node + linkType: hard + "superagent@npm:^10.3.0": version: 10.3.0 resolution: "superagent@npm:10.3.0" @@ -22858,6 +23067,31 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^2.0.0": + version: 2.1.5 + resolution: "tar-fs@npm:2.1.5" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10c0/b987429214c3ab3be1f439b4e097dc310acf449c35f355956d801f59ef4cfde3b9827797ab2d2f087ed024e459eedc9b3cd860c9190b86e356ba348ade928684 + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692 + languageName: node + linkType: hard + "tar@npm:^7.5.4": version: 7.5.7 resolution: "tar@npm:7.5.7" @@ -23130,6 +23364,15 @@ __metadata: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" -- GitLab From b0476939590a25c977c840788d90d72d6ae46949 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 7 Jul 2026 21:45:29 -0600 Subject: [PATCH 2/5] implement share urls --- packages/server/src/api/client/share.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/server/src/api/client/share.ts b/packages/server/src/api/client/share.ts index 31a0de4..4678f15 100644 --- a/packages/server/src/api/client/share.ts +++ b/packages/server/src/api/client/share.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { getOrCached } from "../../controllers/ShareURLController"; +import { Settings } from "../../settings/Settings"; const app = Router(); export default app; @@ -20,6 +21,13 @@ app.get("/preview", async (req, res) => { `public, max-age=${60_000 * 15}, must-revalidate`, ); + const { width, height } = Settings.get("canvas.size"); + + if (x < 0 || y < 0 || x > width || y > height || zoom < 0 || zoom > 5) { + res.status(400).send("Invalid request (position or zoom)"); + return; + } + const [buffer, expiresIn] = await getOrCached(x, y, zoom); res.contentType("png"); -- GitLab From 6fb9b60de5cbd8cf20e8a4974f68e7e7632779f5 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 7 Jul 2026 22:12:19 -0600 Subject: [PATCH 3/5] ci: add packages --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ea73d4d..9b7902b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,6 +16,7 @@ eslint: image: node:24-alpine before_script: - corepack enable yarn && corepack prepare --activate + - apk add --update --no-cache make g++ jpeg-dev cario-dev giflib-dev pango-dev libtool autoconf automake script: - yarn workspaces focus --all - yarn run lint:ci @@ -67,6 +68,7 @@ jest server: DATABASE_URL: postgres://canvas@postgres/canvas before_script: - corepack enable yarn && corepack prepare --activate + - apk add --update --no-cache make g++ jpeg-dev cario-dev giflib-dev pango-dev libtool autoconf automake script: - apk update && apk add git - yarn workspaces focus --all -- GitLab From 274a17ebe2e4e65935b70be51585e56278e78951 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 7 Jul 2026 22:12:47 -0600 Subject: [PATCH 4/5] add dependencies to docker image --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 2ad1759..77c29d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM node:24-alpine AS base # add openssl as root RUN apk add --no-cache openssl +RUN apk add --update --no-cache make g++ jpeg-dev cario-dev giflib-dev pango-dev libtool autoconf automake ARG VERSION -- GitLab From a7c96d633f161ae45437479df19e1ec4cd0f68e2 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 7 Jul 2026 22:14:59 -0600 Subject: [PATCH 5/5] typo --- .gitlab-ci.yml | 4 ++-- Dockerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9b7902b..f590385 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,7 +16,7 @@ eslint: image: node:24-alpine before_script: - corepack enable yarn && corepack prepare --activate - - apk add --update --no-cache make g++ jpeg-dev cario-dev giflib-dev pango-dev libtool autoconf automake + - apk add --update --no-cache make g++ jpeg-dev cairo-dev giflib-dev pango-dev libtool autoconf automake script: - yarn workspaces focus --all - yarn run lint:ci @@ -68,7 +68,7 @@ jest server: DATABASE_URL: postgres://canvas@postgres/canvas before_script: - corepack enable yarn && corepack prepare --activate - - apk add --update --no-cache make g++ jpeg-dev cario-dev giflib-dev pango-dev libtool autoconf automake + - apk add --update --no-cache make g++ jpeg-dev cairo-dev giflib-dev pango-dev libtool autoconf automake script: - apk update && apk add git - yarn workspaces focus --all diff --git a/Dockerfile b/Dockerfile index 77c29d6..efb5983 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:24-alpine AS base # add openssl as root RUN apk add --no-cache openssl -RUN apk add --update --no-cache make g++ jpeg-dev cario-dev giflib-dev pango-dev libtool autoconf automake +RUN apk add --update --no-cache make g++ jpeg-dev cairo-dev giflib-dev pango-dev libtool autoconf automake ARG VERSION -- GitLab