From 49e9f5e64013e0340c0b5a38594c22554a252a2a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 8 Jul 2026 06:38:41 +0000 Subject: [PATCH] Support flexible base paths and thin proxies --- .env.example | 2 +- README.md | 9 +++- compose.yml | 2 +- docs/deploy-titan.md | 25 +++++++++++ proxy/.env.example | 6 +++ proxy/README.md | 62 +++++++++++++++++++++++++ proxy/compose.yml | 16 +++++++ proxy/nginx/render-aishare-proxy.sh | 70 +++++++++++++++++++++++++++++ src/config.ts | 10 +++-- src/server.ts | 60 ++++++++++++++++--------- 10 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 proxy/.env.example create mode 100644 proxy/README.md create mode 100644 proxy/compose.yml create mode 100755 proxy/nginx/render-aishare-proxy.sh diff --git a/.env.example b/.env.example index 14b1069..b003619 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -AISHARE_BASE_URL=https://xcel.me/aishare +AISHARE_BASE_PATH=/aishare AISHARE_ADMIN_TOKEN=change-me AISHARE_DATA_DIR=/data AISHARE_PORT=8080 diff --git a/README.md b/README.md index 3bc1639..87b15fb 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # AI Share -`aishare` captures public ChatGPT shared conversations and publishes immutable read-only snapshots at `https://xcel.me/aishare/`. +`aishare` captures public ChatGPT shared conversations and publishes immutable read-only snapshots under a configurable base path. V1 scope: - accepts only public `https://chatgpt.com/share/...` and `https://chat.openai.com/share/...` URLs -- admin-only capture/delete UI at `/aishare/admin` +- admin-only capture/delete UI at `${AISHARE_BASE_PATH}/admin` - public reader pages generated as static artifacts under `/data/html/` - screenshots under `/data/screenshots/` - SQLite state in `/data/aishare.db` - Docker Compose deployment with Playwright Chromium and multilingual Noto fonts +- relative reader/admin links so the same origin can be proxied from different hostnames and path prefixes ## Local Development @@ -25,6 +26,8 @@ Open: - admin: `http://127.0.0.1:8088/aishare/admin` - public snapshots: `http://127.0.0.1:8088/aishare/` +Set `AISHARE_BASE_PATH=/` to serve at the domain root, or a path such as `/services/aishare` to serve directly under a different prefix. + ## Git Remote ```bash @@ -34,3 +37,5 @@ git remote add origin gitea:cabbage/aishare.git ## Deploy See [docs/deploy-titan.md](docs/deploy-titan.md). + +For small servers that should only proxy to an existing AI Share origin, see [proxy/README.md](proxy/README.md). diff --git a/compose.yml b/compose.yml index 6ddcfae..24da2ec 100644 --- a/compose.yml +++ b/compose.yml @@ -5,7 +5,7 @@ services: restart: unless-stopped user: "1001:1001" environment: - AISHARE_BASE_URL: ${AISHARE_BASE_URL:-https://xcel.me/aishare} + AISHARE_BASE_PATH: ${AISHARE_BASE_PATH:-/aishare} AISHARE_ADMIN_TOKEN: ${AISHARE_ADMIN_TOKEN:?set AISHARE_ADMIN_TOKEN} AISHARE_DATA_DIR: /data AISHARE_PORT: 8080 diff --git a/docs/deploy-titan.md b/docs/deploy-titan.md index 9ed2f2f..b955799 100644 --- a/docs/deploy-titan.md +++ b/docs/deploy-titan.md @@ -17,6 +17,7 @@ cp .env.example .env ``` Edit `/root/compose/aishare/.env` and set `AISHARE_ADMIN_TOKEN`. +Keep `AISHARE_BASE_PATH=/aishare` for the legacy `xcel.me/aishare` endpoint. Start: @@ -55,3 +56,27 @@ Reload nginx: nginx -t docker exec nginx nginx -s reload ``` + +## Proxying From Other Hosts Or Paths + +The app does not require a fixed hostname. Reader pages use relative links, and the admin UI derives API and share URLs from the browser's current URL. + +For direct serving, set the app mount explicitly: + +```env +AISHARE_BASE_PATH=/ +``` + +or: + +```env +AISHARE_BASE_PATH=/services/aishare +``` + +For thin proxy servers that should forward to this origin, use the compose stack in [`../proxy`](../proxy). It can expose URLs such as: + +- `https://aishare.domain.com/` +- `https://domain.com/services/aishare/` +- `https://xcel.me/aishare/` + +while forwarding to an origin such as `https://x1.xcel.me/aishare/`. diff --git a/proxy/.env.example b/proxy/.env.example new file mode 100644 index 0000000..5b7126f --- /dev/null +++ b/proxy/.env.example @@ -0,0 +1,6 @@ +AISHARE_PROXY_BIND=127.0.0.1:8090 +AISHARE_PROXY_SERVER_NAME=_ +AISHARE_PROXY_EXTERNAL_PATH=/aishare +AISHARE_UPSTREAM_ORIGIN=https://origin.example.com +AISHARE_UPSTREAM_PATH=/aishare +AISHARE_PROXY_CLIENT_MAX_BODY_SIZE=20m diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 0000000..0e4e403 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,62 @@ +# AI Share Thin Proxy + +This compose stack runs only nginx and forwards an external hostname/path to an existing AI Share origin. It is intended for small servers where the browser-facing URL may be different from the origin URL. + +The origin service should keep a stable internal mount, usually: + +```env +AISHARE_BASE_PATH=/aishare +``` + +The proxy can expose that same origin as any hostname/path because reader pages use relative links and the admin UI derives API/share URLs from the browser's current URL. + +## Prefix Proxy + +Example: expose `https://domain.com/services/aishare/` and forward to an origin mounted at `/aishare`. + +```bash +cd proxy +cp .env.example .env +``` + +```env +AISHARE_PROXY_BIND=127.0.0.1:8090 +AISHARE_PROXY_SERVER_NAME=domain.com +AISHARE_PROXY_EXTERNAL_PATH=/services/aishare +AISHARE_UPSTREAM_ORIGIN=https://origin.example.com +AISHARE_UPSTREAM_PATH=/aishare +``` + +```bash +docker compose up -d +``` + +Point the server's edge nginx at `http://127.0.0.1:8090`. + +## Root Host Proxy + +Example: expose `https://aishare.domain.com/` and forward to an origin mounted at `/aishare`. + +```env +AISHARE_PROXY_BIND=127.0.0.1:8090 +AISHARE_PROXY_SERVER_NAME=aishare.domain.com +AISHARE_PROXY_EXTERNAL_PATH=/ +AISHARE_UPSTREAM_ORIGIN=https://origin.example.com +AISHARE_UPSTREAM_PATH=/aishare +``` + +## Direct Serving + +If this service is not behind a rewriting proxy, set the app mount directly: + +```env +AISHARE_BASE_PATH=/ +``` + +or: + +```env +AISHARE_BASE_PATH=/services/aishare +``` + +Then route traffic to the AI Share container without path rewriting. diff --git a/proxy/compose.yml b/proxy/compose.yml new file mode 100644 index 0000000..bac7de1 --- /dev/null +++ b/proxy/compose.yml @@ -0,0 +1,16 @@ +services: + aishare-proxy: + image: nginx:1.27-alpine + container_name: aishare-proxy + restart: unless-stopped + environment: + AISHARE_PROXY_SERVER_NAME: ${AISHARE_PROXY_SERVER_NAME:-_} + AISHARE_PROXY_LISTEN: ${AISHARE_PROXY_LISTEN:-8080} + AISHARE_PROXY_EXTERNAL_PATH: ${AISHARE_PROXY_EXTERNAL_PATH:-/aishare} + AISHARE_UPSTREAM_ORIGIN: ${AISHARE_UPSTREAM_ORIGIN:?set AISHARE_UPSTREAM_ORIGIN, for example https://origin.example.com} + AISHARE_UPSTREAM_PATH: ${AISHARE_UPSTREAM_PATH:-/aishare} + AISHARE_PROXY_CLIENT_MAX_BODY_SIZE: ${AISHARE_PROXY_CLIENT_MAX_BODY_SIZE:-20m} + ports: + - "${AISHARE_PROXY_BIND:-127.0.0.1:8090}:${AISHARE_PROXY_LISTEN:-8080}" + volumes: + - ./nginx/render-aishare-proxy.sh:/docker-entrypoint.d/40-render-aishare-proxy.sh:ro diff --git a/proxy/nginx/render-aishare-proxy.sh b/proxy/nginx/render-aishare-proxy.sh new file mode 100755 index 0000000..d16ea61 --- /dev/null +++ b/proxy/nginx/render-aishare-proxy.sh @@ -0,0 +1,70 @@ +#!/bin/sh +set -eu + +normalize_path() { + value="${1:-}" + value="/$(printf '%s' "$value" | sed -e 's#^/*##' -e 's#/*$##')" + if [ "$value" = "/" ]; then + printf '' + else + printf '%s' "$value" + fi +} + +external_path="$(normalize_path "${AISHARE_PROXY_EXTERNAL_PATH:-/aishare}")" +upstream_path="$(normalize_path "${AISHARE_UPSTREAM_PATH:-/aishare}")" +upstream_origin="$(printf '%s' "${AISHARE_UPSTREAM_ORIGIN:?set AISHARE_UPSTREAM_ORIGIN}" | sed -e 's#/*$##')" +listen_port="${AISHARE_PROXY_LISTEN:-8080}" +server_name="${AISHARE_PROXY_SERVER_NAME:-_}" +client_max_body_size="${AISHARE_PROXY_CLIENT_MAX_BODY_SIZE:-20m}" +external_slash="${external_path}/" +upstream_slash="${upstream_path}/" +if [ -z "$external_path" ]; then external_slash="/"; fi +if [ -z "$upstream_path" ]; then upstream_slash="/"; fi + +cat > /etc/nginx/conf.d/default.conf <> /etc/nginx/conf.d/default.conf <> /etc/nginx/conf.d/default.conf <> /etc/nginx/conf.d/default.conf <<'EOF' +} +EOF diff --git a/src/config.ts b/src/config.ts index be6e862..fa055e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,6 @@ import path from "node:path"; export interface AppConfig { basePath: string; - baseUrl: string; port: number; dataDir: string; htmlDir: string; @@ -21,11 +20,16 @@ function intEnv(name: string, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +function basePathEnv(): string { + const raw = (process.env.AISHARE_BASE_PATH || "/aishare").trim(); + if (!raw || raw === "/") return ""; + return `/${raw.replace(/^\/+|\/+$/g, "")}`; +} + const dataDir = process.env.AISHARE_DATA_DIR || path.resolve("data"); export const config: AppConfig = { - basePath: "/aishare", - baseUrl: (process.env.AISHARE_BASE_URL || "http://127.0.0.1:8088/aishare").replace(/\/$/, ""), + basePath: basePathEnv(), port: intEnv("AISHARE_PORT", 8080), dataDir, htmlDir: path.join(dataDir, "html"), diff --git a/src/server.ts b/src/server.ts index 6336996..b6ceba5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,6 +24,16 @@ app.disable("x-powered-by"); app.enable("strict routing"); app.use(express.json({ limit: "1mb" })); +function route(pathname = ""): string { + if (!pathname) return config.basePath || "/"; + const suffix = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `${config.basePath}${suffix}` || "/"; +} + +function publicPath(slug: string): string { + return `${config.basePath}/${slug}/` || `/${slug}/`; +} + function requireAdmin(req: Request, res: Response, next: NextFunction): void { if (!config.adminToken) { res.status(500).json({ error: "AISHARE_ADMIN_TOKEN is not configured." }); @@ -51,7 +61,7 @@ function publicCapture(row: ReturnType) { createdAt: row.created_at, updatedAt: row.updated_at, capturedAt: row.captured_at, - publicUrl: row.slug ? `${config.baseUrl}/${row.slug}/` : null + publicUrl: row.slug ? publicPath(row.slug) : null }; } @@ -66,18 +76,18 @@ function safeSendFrom(root: string, requested: string, res: Response): void { res.sendFile(target); } -app.use(`${config.basePath}/assets`, express.static(path.resolve("dist-client/assets"), { immutable: true, maxAge: "1y" })); -app.use(`${config.basePath}/admin-assets`, express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" })); +app.use(route("/assets"), express.static(path.resolve("dist-client/assets"), { immutable: true, maxAge: "1y" })); +app.use(route("/admin-assets"), express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" })); -app.get(`${config.basePath}/admin`, (_req, res) => { +app.get(route("/admin"), (_req, res) => { res.sendFile(path.resolve("dist-client/index.html")); }); -app.get(`${config.basePath}/api/health`, (_req, res) => { +app.get(route("/api/health"), (_req, res) => { res.json({ ok: true }); }); -app.post(`${config.basePath}/api/captures`, requireAdmin, (req, res) => { +app.post(route("/api/captures"), requireAdmin, (req, res) => { try { const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || "")); const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null; @@ -89,7 +99,7 @@ app.post(`${config.basePath}/api/captures`, requireAdmin, (req, res) => { } }); -app.get(`${config.basePath}/api/captures`, requireAdmin, (req, res) => { +app.get(route("/api/captures"), requireAdmin, (req, res) => { const page = Number.parseInt(String(req.query.page || "1"), 10); const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 10); const result = store.listCaptures(page, pageSize); @@ -101,7 +111,7 @@ app.get(`${config.basePath}/api/captures`, requireAdmin, (req, res) => { }); }); -app.get(`${config.basePath}/api/captures/:id`, requireAdmin, (req, res) => { +app.get(route("/api/captures/:id"), requireAdmin, (req, res) => { const row = store.getCapture(req.params.id); if (!row) { res.status(404).json({ error: "Capture not found." }); @@ -110,7 +120,7 @@ app.get(`${config.basePath}/api/captures/:id`, requireAdmin, (req, res) => { res.json({ capture: publicCapture(row), errorDetail: row.error_detail, metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null }); }); -app.post(`${config.basePath}/api/captures/:id/retry`, requireAdmin, (req, res) => { +app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => { const row = store.getCapture(req.params.id); if (!row) { res.status(404).json({ error: "Capture not found." }); @@ -125,7 +135,7 @@ app.post(`${config.basePath}/api/captures/:id/retry`, requireAdmin, (req, res) = res.json({ capture: publicCapture(store.getCapture(row.id)) }); }); -app.delete(`${config.basePath}/api/captures/:id`, requireAdmin, async (req, res) => { +app.delete(route("/api/captures/:id"), requireAdmin, async (req, res) => { const row = store.deleteCapture(req.params.id); if (!row) { res.status(404).json({ error: "Capture not found." }); @@ -135,28 +145,28 @@ app.delete(`${config.basePath}/api/captures/:id`, requireAdmin, async (req, res) res.json({ ok: true }); }); -app.delete(`${config.basePath}/api/captures`, requireAdmin, async (_req, res) => { +app.delete(route("/api/captures"), requireAdmin, async (_req, res) => { store.deleteAllCaptures(); await deleteAllArtifactRoots(); res.json({ ok: true }); }); -app.get(`${config.basePath}/:slug/assets/*`, (req, res) => { +app.get(route("/:slug/assets/*"), (req, res) => { const wildcard = (req.params as Record)[0] || ""; safeSendFrom(artifactAssetsDir(req.params.slug), wildcard, res); }); -app.get(`${config.basePath}/:slug/screenshots/:variant.png`, (req, res) => { +app.get(route("/:slug/screenshots/:variant.png"), (req, res) => { res.sendFile(screenshotPath(req.params.slug, req.params.variant)); }); -app.get(`${config.basePath}/:slug/screenshot.png`, (req, res) => { +app.get(route("/:slug/screenshot.png"), (req, res) => { const device = req.query.device === "mobile" ? "mobile" : "desktop"; const theme = req.query.theme === "dark" ? "dark" : "light"; res.sendFile(screenshotPath(req.params.slug, `${device}-${theme}`)); }); -app.get(`${config.basePath}/:slug/download.html`, (req, res) => { +app.get(route("/:slug/download.html"), (req, res) => { const row = store.getCaptureBySlug(req.params.slug); if (!row) { res.status(404).send("Not found"); @@ -165,7 +175,7 @@ app.get(`${config.basePath}/:slug/download.html`, (req, res) => { res.download(artifactIndexPath(req.params.slug), `${req.params.slug}.html`); }); -app.get(`${config.basePath}/:slug/download`, async (req, res, next) => { +app.get(route("/:slug/download"), async (req, res, next) => { try { const row = store.getCaptureBySlug(req.params.slug); if (!row) { @@ -187,7 +197,7 @@ app.get(`${config.basePath}/:slug/download`, async (req, res, next) => { } }); -app.get(`${config.basePath}/:slug/download.zip`, async (req, res, next) => { +app.get(route("/:slug/download.zip"), async (req, res, next) => { try { const row = store.getCaptureBySlug(req.params.slug); if (!row) { @@ -202,7 +212,7 @@ app.get(`${config.basePath}/:slug/download.zip`, async (req, res, next) => { } }); -app.get(`${config.basePath}/:slug/`, async (req, res) => { +app.get(route("/:slug/"), async (req, res) => { const row = store.getCaptureBySlug(req.params.slug); if (!row) { res.status(404).send("Not found"); @@ -218,14 +228,20 @@ app.get(`${config.basePath}/:slug/`, async (req, res) => { .send(`${row.title || "AI Share"}

${row.error_public || `Capture is ${row.status}.`}

`); }); -app.get(`${config.basePath}/:slug`, (req, res) => { - res.redirect(308, `${config.basePath}/${req.params.slug}/`); +app.get(route("/:slug"), (req, res) => { + res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`); }); -app.get(config.basePath, (_req, res) => { - res.redirect(301, `${config.basePath}/admin`); +app.get(route(), (_req, res) => { + res.redirect(301, route("/admin")); }); +if (config.basePath) { + app.get(route("/"), (_req, res) => { + res.redirect(301, route("/admin")); + }); +} + app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { console.error(error); res.status(500).json({ error: "Internal server error." });