Support flexible base paths and thin proxies
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
AISHARE_BASE_URL=https://xcel.me/aishare
|
AISHARE_BASE_PATH=/aishare
|
||||||
AISHARE_ADMIN_TOKEN=change-me
|
AISHARE_ADMIN_TOKEN=change-me
|
||||||
AISHARE_DATA_DIR=/data
|
AISHARE_DATA_DIR=/data
|
||||||
AISHARE_PORT=8080
|
AISHARE_PORT=8080
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
# AI Share
|
# AI Share
|
||||||
|
|
||||||
`aishare` captures public ChatGPT shared conversations and publishes immutable read-only snapshots at `https://xcel.me/aishare/<slug>`.
|
`aishare` captures public ChatGPT shared conversations and publishes immutable read-only snapshots under a configurable base path.
|
||||||
|
|
||||||
V1 scope:
|
V1 scope:
|
||||||
|
|
||||||
- accepts only public `https://chatgpt.com/share/...` and `https://chat.openai.com/share/...` URLs
|
- 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/<slug>`
|
- public reader pages generated as static artifacts under `/data/html/<slug>`
|
||||||
- screenshots under `/data/screenshots/<slug>`
|
- screenshots under `/data/screenshots/<slug>`
|
||||||
- SQLite state in `/data/aishare.db`
|
- SQLite state in `/data/aishare.db`
|
||||||
- Docker Compose deployment with Playwright Chromium and multilingual Noto fonts
|
- 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
|
## Local Development
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ Open:
|
|||||||
- admin: `http://127.0.0.1:8088/aishare/admin`
|
- admin: `http://127.0.0.1:8088/aishare/admin`
|
||||||
- public snapshots: `http://127.0.0.1:8088/aishare/<slug>`
|
- public snapshots: `http://127.0.0.1:8088/aishare/<slug>`
|
||||||
|
|
||||||
|
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
|
## Git Remote
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -34,3 +37,5 @@ git remote add origin gitea:cabbage/aishare.git
|
|||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
See [docs/deploy-titan.md](docs/deploy-titan.md).
|
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).
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
environment:
|
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_ADMIN_TOKEN: ${AISHARE_ADMIN_TOKEN:?set AISHARE_ADMIN_TOKEN}
|
||||||
AISHARE_DATA_DIR: /data
|
AISHARE_DATA_DIR: /data
|
||||||
AISHARE_PORT: 8080
|
AISHARE_PORT: 8080
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ cp .env.example .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
Edit `/root/compose/aishare/.env` and set `AISHARE_ADMIN_TOKEN`.
|
Edit `/root/compose/aishare/.env` and set `AISHARE_ADMIN_TOKEN`.
|
||||||
|
Keep `AISHARE_BASE_PATH=/aishare` for the legacy `xcel.me/aishare` endpoint.
|
||||||
|
|
||||||
Start:
|
Start:
|
||||||
|
|
||||||
@@ -55,3 +56,27 @@ Reload nginx:
|
|||||||
nginx -t
|
nginx -t
|
||||||
docker exec nginx nginx -s reload
|
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/`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Executable
+70
@@ -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 <<EOF
|
||||||
|
server {
|
||||||
|
listen ${listen_port};
|
||||||
|
server_name ${server_name};
|
||||||
|
|
||||||
|
client_max_body_size ${client_max_body_size};
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Host \$proxy_host;
|
||||||
|
proxy_set_header X-Forwarded-Host \$http_host;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix ${external_path:-/};
|
||||||
|
proxy_set_header X-Real-Ip \$remote_addr;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
write_proxy_block() {
|
||||||
|
location_line="$1"
|
||||||
|
rewrite_line="$2"
|
||||||
|
cat >> /etc/nginx/conf.d/default.conf <<EOF
|
||||||
|
|
||||||
|
${location_line} {
|
||||||
|
${rewrite_line}
|
||||||
|
proxy_redirect ${upstream_slash} ${external_slash};
|
||||||
|
proxy_redirect ${upstream_origin}${upstream_slash} \$scheme://\$http_host${external_slash};
|
||||||
|
proxy_pass ${upstream_origin};
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$external_path" ]; then
|
||||||
|
write_proxy_block "location /" "rewrite ^/(.*)\$ ${upstream_path}/\$1 break;"
|
||||||
|
else
|
||||||
|
cat >> /etc/nginx/conf.d/default.conf <<EOF
|
||||||
|
|
||||||
|
location = ${external_path} {
|
||||||
|
return 301 ${external_path}/;
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
write_proxy_block "location ^~ ${external_path}/" "rewrite ^${external_path}/?(.*)\$ ${upstream_path}/\$1 break;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> /etc/nginx/conf.d/default.conf <<'EOF'
|
||||||
|
}
|
||||||
|
EOF
|
||||||
+7
-3
@@ -2,7 +2,6 @@ import path from "node:path";
|
|||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
basePath: string;
|
basePath: string;
|
||||||
baseUrl: string;
|
|
||||||
port: number;
|
port: number;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
htmlDir: string;
|
htmlDir: string;
|
||||||
@@ -21,11 +20,16 @@ function intEnv(name: string, fallback: number): number {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
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");
|
const dataDir = process.env.AISHARE_DATA_DIR || path.resolve("data");
|
||||||
|
|
||||||
export const config: AppConfig = {
|
export const config: AppConfig = {
|
||||||
basePath: "/aishare",
|
basePath: basePathEnv(),
|
||||||
baseUrl: (process.env.AISHARE_BASE_URL || "http://127.0.0.1:8088/aishare").replace(/\/$/, ""),
|
|
||||||
port: intEnv("AISHARE_PORT", 8080),
|
port: intEnv("AISHARE_PORT", 8080),
|
||||||
dataDir,
|
dataDir,
|
||||||
htmlDir: path.join(dataDir, "html"),
|
htmlDir: path.join(dataDir, "html"),
|
||||||
|
|||||||
+38
-22
@@ -24,6 +24,16 @@ app.disable("x-powered-by");
|
|||||||
app.enable("strict routing");
|
app.enable("strict routing");
|
||||||
app.use(express.json({ limit: "1mb" }));
|
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 {
|
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||||
if (!config.adminToken) {
|
if (!config.adminToken) {
|
||||||
res.status(500).json({ error: "AISHARE_ADMIN_TOKEN is not configured." });
|
res.status(500).json({ error: "AISHARE_ADMIN_TOKEN is not configured." });
|
||||||
@@ -51,7 +61,7 @@ function publicCapture(row: ReturnType<typeof store.getCapture>) {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
capturedAt: row.captured_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);
|
res.sendFile(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(`${config.basePath}/assets`, express.static(path.resolve("dist-client/assets"), { immutable: true, maxAge: "1y" }));
|
app.use(route("/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("/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"));
|
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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(`${config.basePath}/api/captures`, requireAdmin, (req, res) => {
|
app.post(route("/api/captures"), requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || ""));
|
const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || ""));
|
||||||
const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null;
|
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 page = Number.parseInt(String(req.query.page || "1"), 10);
|
||||||
const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 10);
|
const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 10);
|
||||||
const result = store.listCaptures(page, pageSize);
|
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);
|
const row = store.getCapture(req.params.id);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
res.status(404).json({ error: "Capture not found." });
|
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 });
|
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);
|
const row = store.getCapture(req.params.id);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
res.status(404).json({ error: "Capture not found." });
|
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)) });
|
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);
|
const row = store.deleteCapture(req.params.id);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
res.status(404).json({ error: "Capture not found." });
|
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 });
|
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();
|
store.deleteAllCaptures();
|
||||||
await deleteAllArtifactRoots();
|
await deleteAllArtifactRoots();
|
||||||
res.json({ ok: true });
|
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<string, string>)[0] || "";
|
const wildcard = (req.params as Record<string, string>)[0] || "";
|
||||||
safeSendFrom(artifactAssetsDir(req.params.slug), wildcard, res);
|
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));
|
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 device = req.query.device === "mobile" ? "mobile" : "desktop";
|
||||||
const theme = req.query.theme === "dark" ? "dark" : "light";
|
const theme = req.query.theme === "dark" ? "dark" : "light";
|
||||||
res.sendFile(screenshotPath(req.params.slug, `${device}-${theme}`));
|
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);
|
const row = store.getCaptureBySlug(req.params.slug);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
res.status(404).send("Not found");
|
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`);
|
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 {
|
try {
|
||||||
const row = store.getCaptureBySlug(req.params.slug);
|
const row = store.getCaptureBySlug(req.params.slug);
|
||||||
if (!row) {
|
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 {
|
try {
|
||||||
const row = store.getCaptureBySlug(req.params.slug);
|
const row = store.getCaptureBySlug(req.params.slug);
|
||||||
if (!row) {
|
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);
|
const row = store.getCaptureBySlug(req.params.slug);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
res.status(404).send("Not found");
|
res.status(404).send("Not found");
|
||||||
@@ -218,14 +228,20 @@ app.get(`${config.basePath}/:slug/`, async (req, res) => {
|
|||||||
.send(`<!doctype html><meta name="robots" content="noindex"><title>${row.title || "AI Share"}</title><p>${row.error_public || `Capture is ${row.status}.`}</p>`);
|
.send(`<!doctype html><meta name="robots" content="noindex"><title>${row.title || "AI Share"}</title><p>${row.error_public || `Capture is ${row.status}.`}</p>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(`${config.basePath}/:slug`, (req, res) => {
|
app.get(route("/:slug"), (req, res) => {
|
||||||
res.redirect(308, `${config.basePath}/${req.params.slug}/`);
|
res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(config.basePath, (_req, res) => {
|
app.get(route(), (_req, res) => {
|
||||||
res.redirect(301, `${config.basePath}/admin`);
|
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) => {
|
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).json({ error: "Internal server error." });
|
res.status(500).json({ error: "Internal server error." });
|
||||||
|
|||||||
Reference in New Issue
Block a user