diff --git a/.gitignore b/.gitignore index 870917d..7bbc71c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# Project-specific stuff: -gh-md-toc - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/Dockerfile b/Dockerfile index cf99265..cdad17a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,16 @@ -FROM python:3.7-slim AS base +FROM python:3.9.23-slim AS base FROM base AS builder +WORKDIR /builddir + COPY requirements.txt . # We're not going to run anything in the build container, so we'll suppress the script location warnings. RUN pip install --user --no-warn-script-location -r requirements.txt - +ENV PATH=/root/.local/bin:$PATH +COPY typecheck . +COPY src src +RUN ./typecheck FROM base @@ -13,10 +18,10 @@ WORKDIR /app COPY --from=builder /root/.local/lib /root/.local/lib COPY --from=builder /root/.local/bin/mitmdump /root/.local/bin/mitmdump +COPY --from=builder /builddir/src src ENV PATH=/root/.local/bin:$PATH -COPY src src COPY default-rules default-rules COPY default-userscripts default-userscripts EXPOSE 8080 -ENTRYPOINT [ "python", "-u", "src/launcher.py" ] +ENTRYPOINT [ "python", "-u", "src/restartable_launcher.py" ] diff --git a/FUNDING.yml b/FUNDING.yml new file mode 100644 index 0000000..b669b04 --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: alling diff --git a/Makefile b/Makefile index 7f55c74..3c838a7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,3 @@ -TOC_FILE = gh-md-toc -TOC_HASH = 042fc595336c3a39f82b1edbafdf2afd2503d9930d192fcfda757aa65522c14c -TOC_URL = https://raw.githubusercontent.com/ekalinin/github-markdown-toc/56f7c5939e2119bed86291ddba9fb6c2ee61fb09/gh-md-toc - DEFAULT_TAG = latest TAG ?= $(DEFAULT_TAG) @@ -16,17 +12,6 @@ CA_DIR = /root/.mitmproxy .PHONY : all all: image -docs: - wget -O $(TOC_FILE) $(TOC_URL) -# Check that the file hasn't been tampered with: - echo "$(TOC_HASH) $(TOC_FILE)" | sha256sum -c - chmod +x $(TOC_FILE) -# Generate and insert TOC: - ./$(TOC_FILE) --insert README.md -# Remove files created by gh-md-toc: - rm README.md.orig.* - rm README.md.toc.* - image: docker build -t $(DOCKER_USER)/$(DOCKER_REPO):$(TAG) . @@ -42,12 +27,15 @@ ifeq "$(TAG)" "$(DEFAULT_TAG)" endif # Update in-app version: sed -i 's/^VERSION: str = "[^"]*"/VERSION: str = "$(TAG)"/' $(FILE_WITH_VERSION) +# Update readme version: + sed -i 's#image: alling/userscript-proxy:.*#image: alling/userscript-proxy:$(TAG)#' README.md docker build -t $(DOCKER_USER)/$(DOCKER_REPO):$(TAG) . docker tag $(DOCKER_USER)/$(DOCKER_REPO):$(TAG) $(DOCKER_USER)/$(DOCKER_REPO):$(DEFAULT_TAG) - git add $(FILE_WITH_VERSION) + git add $(FILE_WITH_VERSION) README.md git commit -m "v$(TAG)" git tag "v$(TAG)" + echo "Run these commands to push to Docker Hub:\n\n docker push alling/userscript-proxy:$(TAG)\n docker push alling/userscript-proxy:$(DEFAULT_TAG)\n" start: image # The -t flag enables colored output: - docker run -t --rm -p 8080:8080 --name $(DOCKER_REPO) -v "$(CA_VOLUME):$(CA_DIR)" $(DOCKER_USER)/$(DOCKER_REPO):$(TAG) + docker run -t --rm -p 8080:8080 -p 8765:8765 --name $(DOCKER_REPO) -v "$(CA_VOLUME):$(CA_DIR)" $(DOCKER_USER)/$(DOCKER_REPO):$(TAG) diff --git a/README.md b/README.md index af71fd5..511a23d 100644 --- a/README.md +++ b/README.md @@ -6,42 +6,35 @@ No jailbreak/root required. Userscript Proxy is built around [mitmproxy][mitmproxy] and acts as a MITM, injecting userscripts into web pages as they flow through it. Both HTTP and HTTPS are supported. - - * [Userscript Proxy](#userscript-proxy) - * [Getting started](#getting-started) - * [Security notice](#security-notice) - * [Starting the proxy](#starting-the-proxy) - * [On a mobile device](#on-a-mobile-device) - * [HTTPS](#https) - * [Android](#android) - * [iOS](#ios) - * [Deploying userscripts](#deploying-userscripts) - * [Apps with certificate pinning](#apps-with-certificate-pinning) - * [Basic pattern](#basic-pattern) - * [Examples](#examples) - * [Regular expression](#regular-expression) - * [Examples](#examples-1) - * [Data usage](#data-usage) - * [Userscript compatibility](#userscript-compatibility) - * [Options](#options) - * [--inline, -i](#--inline--i) - * [--list-injected, -l](#--list-injected--l) - * [--no-default-rules](#--no-default-rules) - * [--no-default-userscripts](#--no-default-userscripts) - * [--port PORT, -p PORT](#--port-port--p-port) - * [--query-param-to-disable PARAM, -q PARAM](#--query-param-to-disable-param--q-param) - * [--rules FILE](#--rules-file) - * [--transparent, -t](#--transparent--t) - * [--userscripts-dir DIR, -u DIR](#--userscripts-dir-dir--u-dir) - * [Contribute](#contribute) - - - - - # Getting started +If you're familiar with Userscript Proxy, you might want to use Docker Compose: + +```yaml +services: + userscript-proxy: + image: alling/userscript-proxy:1.1.2 + container_name: userscript-proxy + command: + - --userscripts-dir + - /my-userscripts + - --rules + - /my-rules/ignore.txt + ports: + - "8080:8080" + volumes: + - mitmproxy-ca:/root/.mitmproxy + - /absolute/path/to/my/userscripts/:/my-userscripts # Modify the part before the ':'! + - /absolute/path/to/my/rules/:/my-rules # Modify the part before the ':'! + restart: always + +volumes: + mitmproxy-ca: +``` + +Otherwise, keep reading. + ## Security notice Make sure you understand these security aspects before using Userscript Proxy: @@ -54,16 +47,11 @@ Make sure you understand these security aspects before using Userscript Proxy: ## Starting the proxy 1. Make sure you have [Docker](https://www.docker.com) installed. - This should work: - - ``` - docker --version - ``` 1. Start Userscript Proxy: ``` - docker run -t --rm --name userscript-proxy -p 8080:8080 alling/userscript-proxy + docker run -t --rm --name userscript-proxy -p 8080:8080 -v "mitmproxy-ca:/root/.mitmproxy" alling/userscript-proxy ``` When you see _Proxy server listening at http://*:8080_, the proxy is up and running. @@ -90,47 +78,17 @@ Make sure you understand these security aspects before using Userscript Proxy: 1. You need to know the local IP address of the machine running Userscript Proxy (i.e. where you ran `docker run` above). This is usually something like `192.168.1.67`. - You can typically [find it](https://google.com/search?q=find+local+IP+address) by running `ip a`, `ifconfig` or `ipconfig` depending on your operating system. - - If your local IP address is `192.168.1.67`, and the proxy is running (see above), this should work: - - ``` - curl --proxy 192.168.1.67:8080 http://example.com - ``` - -1. Your mobile device needs to be on the same LAN as your proxy, so make sure it's connected to your Wi-Fi. + You can typically [find it](https://google.com/search?q=find+local+IP+address) by running `ip a`, `ifconfig` or `ipconfig`. 1. On your mobile device, go to the settings for the currently active Wi-Fi connection. - Find the proxy settings, select **Manual proxy** or similar, and set `192.168.1.67` with port `8080`. + Find the proxy settings, select **Manual proxy** or similar, and set e.g. `192.168.1.67` with port `8080`. 1. Visit [`http://example.com`](http://example.com) on your mobile device. You should see the same green page as above. ## HTTPS -When you've set up Userscript Proxy on your mobile device as described above, you'll notice that you can't visit sites via HTTPS anymore. -This is because your device thinks you're being [MITM'd](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) (which, technically, you are – by yourself). - -To make HTTPS connections work, you need to tell your device that it should trust your proxy. -This is accomplished by installing a certificate. - -**In general, installing a certificate might pose a security risk. If you don't trust me and mitmproxy, stop here.** -Otherwise, read on. - -1. Stop the proxy by pressing `Ctrl` + `C` in the terminal where it's running. - Then start it again, this time with the `-v` flag as shown below: - - ``` - docker run -t --rm --name userscript-proxy -p 8080:8080 -v "mitmproxy-ca:/root/.mitmproxy" alling/userscript-proxy - ``` - - This creates a new Docker volume and mounts it at `/root/.mitmproxy`, where mitmproxy stores its certificate authority files. - This is necessary so that you can restart the proxy later without having to perform all these steps again. - - In this example, `mitmproxy-ca` is the name of the new Docker volume. - You can choose any name you want, as long as it's not already in use. - -1. Make sure your mobile device is configured to use the proxy as decribed above. +To make HTTPS work, you need to make your device trust your proxy by installing a certificate generated by mitmproxy. 1. On your mobile device, go to [http://mitm.it](http://mitm.it). You should see icons for Apple, Windows, Android, etc. @@ -158,12 +116,10 @@ Otherwise, read on. 1. Under _Enable full trust for root certificates_, enable **mitmproxy**, confirming the action if prompted. -1. You should now be able to browse via HTTPS as usual. - ## Deploying userscripts Userscript Proxy comes with one single userscript, useful only for testing that the proxy is up and running. -To use userscripts you've downloaded or written yourself, you need to tell Userscript Proxy where they are. +To use userscripts you've downloaded or written yourself: 1. You need the **absolute path** to a directory containing your userscripts. This could be something like `/home/alling/userscripts`. @@ -171,12 +127,9 @@ To use userscripts you've downloaded or written yourself, you need to tell Users 1. Run Userscript Proxy like this: ``` - docker run -t --rm --name userscript-proxy -p 8080:8080 -v "mitmproxy-ca:/root/.mitmproxy" -v "/home/alling/userscripts:/userscripts" alling/userscript-proxy --userscripts-dir "/userscripts" + docker run -t --rm --name userscript-proxy -p 8080:8080 -v "mitmproxy-ca:/root/.mitmproxy" -v "/home/alling/userscripts:/my-userscripts" alling/userscript-proxy --userscripts-dir "/my-userscripts" ``` - * `-v "/home/alling/userscripts:/userscripts"` mounts your userscripts directory at `/userscripts` inside the Docker container. - * `--userscripts-dir "/userscripts"` tells Userscript Proxy to read userscripts from `/userscripts`. - # Apps with certificate pinning @@ -197,11 +150,11 @@ Examples: * Take ignore rules from `/home/alling/rules/ignore.txt`: ```bash - docker run -t --rm -v "/home/alling/rules:/rules" alling/userscript-proxy --rules "/rules/ignore.txt" + docker run -t --rm -v "/home/alling/rules:/my-rules" alling/userscript-proxy --rules "/my-rules/ignore.txt" ``` * Take intercept rules from all `.txt` files in the `/home/alling/rules` directory whose names start with `foo`: ```bash - docker run -t --rm -v "/home/alling/rules:/rules" alling/userscript-proxy --rules "/rules/foo*.txt" --intercept + docker run -t --rm -v "/home/alling/rules:/my-rules" alling/userscript-proxy --rules "/my-rules/foo*.txt" --intercept ``` Rules can be specified in two ways: @@ -256,7 +209,7 @@ A userscript is injected by reference if and only if it has a specified `@downlo (This can be overridden using the `--inline` flag, in which case all userscripts are injected inline.) Userscripts are injected into _every_ response from a matching URL, and the size of a userscript can be anything from a few hundred bytes for the most basic ones to hundreds of kilobytes in extreme cases, so there are _massive_ data usage reductions to be gained from making the userscript accessible by URL and including a `@downloadURL`. -If the `@downloadURL` approach is not possible, for one reason or the other, it is a good idea to be aware of this issue, and to take appropriate action such as [minifying][minification] userscripts and adding suitable ignore rules. +If the `@downloadURL` approach is not possible, for one reason or the other, it is a good idea to be aware of this issue, and to take appropriate action such as [minifying] userscripts and adding suitable ignore rules. # Userscript compatibility @@ -287,6 +240,15 @@ docker run -t --rm --name userscript-proxy -p 8080:8080 alling/userscript-proxy # flags to `docker run` flags to Userscript Proxy ``` +## `--bypass-csp ALLOW` + +Bypass host site's Content Security Policy (if any) to allow userscripts to run properly. +If `ALLOW` is `script`, the CSP is bypassed only for the userscript itself. +Use `nothing` to never bypass any CSP (meaning userscripts won't work at all on some sites). +Use `everything` to allow everything, which may be necessary if the userscript injects CSS, images etc. +Note that the latter completely disables any CSP from every host site into which a userscript is injected. +Defaults to `script`. + ## `--inline`, `-i` Always inject scripts inline (``), never linked (``). @@ -364,7 +326,7 @@ make start [mitmproxy]: https://mitmproxy.org -[minification]: https://en.wikipedia.org/wiki/Minification_(programming) +[minifying]: https://en.wikipedia.org/wiki/Minification_(programming) [metadata]: https://wiki.greasespot.net/Metadata_Block [transparent-mode]: https://docs.mitmproxy.org/stable/concepts-modes/#transparent-proxy [gm-api]: https://wiki.greasespot.net/GM.getValue diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore new file mode 100644 index 0000000..6a5f41f --- /dev/null +++ b/admin-ui/.gitignore @@ -0,0 +1,12 @@ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +node_modules/ +dist/ + +.vscode/ +.idea/ +.DS_Store diff --git a/admin-ui/Dockerfile b/admin-ui/Dockerfile new file mode 100644 index 0000000..e00ae42 --- /dev/null +++ b/admin-ui/Dockerfile @@ -0,0 +1,47 @@ +FROM node:24.14.0-bookworm-slim AS builder + +WORKDIR /build + +ARG BACKEND_HOST=localhost +ARG BACKEND_PORT=3000 + +COPY package.json package-lock.json ./ +COPY core/package.json ./core/ +COPY backend/package.json ./backend/ +COPY frontend/package.json ./frontend/ +RUN npm ci --no-audit --no-fund + +COPY .gitignore ./ +COPY tsconfig.base.json ./ +COPY tsconfig.json ./ +COPY core/tsconfig.json ./core/ +COPY backend/tsconfig.json ./backend/ +COPY frontend/tsconfig.json ./frontend/ +COPY frontend/tsconfig.app.json ./frontend/ +COPY frontend/tsconfig.node.json ./frontend/ +COPY eslint.config.js ./ +COPY prettier.config.ts ./ +COPY frontend/vite.config.ts ./frontend/ +COPY frontend/index.html ./frontend/ +COPY frontend/public/ ./frontend/public/ + +COPY core/src/ ./core/src/ +COPY backend/src/ ./backend/src/ +COPY frontend/src/ ./frontend/src/ +RUN npm run build +RUN npm run lint +RUN npm run format:check + + +FROM node:24.14.0-bookworm-slim AS runtime + +WORKDIR /app + +ENV FRONTEND_DIR=/app/public + +COPY --from=builder /build/backend/dist/ . +COPY --from=builder /build/frontend/dist/ ${FRONTEND_DIR:?} + +EXPOSE $BACKEND_PORT + +CMD ["node", "index.cjs"] diff --git a/admin-ui/backend/package.json b/admin-ui/backend/package.json new file mode 100644 index 0000000..091b4a8 --- /dev/null +++ b/admin-ui/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "@userscript-proxy/backend", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json && esbuild --tsconfig=tsconfig.json --bundle src/index.ts --outfile=dist/index.cjs --platform=node --minify", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@fastify/static": "^9.0.0", + "@userscript-proxy/core": "*", + "fastify": "^5.8.2" + } +} diff --git a/admin-ui/backend/src/app.ts b/admin-ui/backend/src/app.ts new file mode 100644 index 0000000..7d7bdb7 --- /dev/null +++ b/admin-ui/backend/src/app.ts @@ -0,0 +1,244 @@ +import fastifyStatic from "@fastify/static"; +import Fastify from "fastify"; + +import { type BadRequestErrorBody } from "@userscript-proxy/core/api/BadRequestErrorBody"; +import { + type CreateScriptRequest, + CreateScriptRequestCodec, +} from "@userscript-proxy/core/api/CreateScriptRequest"; +import type { HealthStatus } from "@userscript-proxy/core/api/HealthStatus"; +import { type InternalServerErrorBody } from "@userscript-proxy/core/api/InternalServerErrorBody"; +import { type ScriptAlreadyExistsErrorBody } from "@userscript-proxy/core/api/ScriptAlreadyExistsErrorBody"; +import type { ScriptDetails } from "@userscript-proxy/core/api/ScriptDetails"; +import { type ScriptNotFoundErrorBody } from "@userscript-proxy/core/api/ScriptNotFoundErrorBody"; +import type { ScriptSummary } from "@userscript-proxy/core/api/ScriptSummary"; +import { + type UpdateScriptRequest, + UpdateScriptRequestCodec, +} from "@userscript-proxy/core/api/UpdateScriptRequest"; +import { assertExhausted } from "@userscript-proxy/core/assertions"; +import { decodeWith } from "@userscript-proxy/core/decoding"; +import { + isUserscriptFilename, + withExtension, +} from "@userscript-proxy/core/files"; +import { quote } from "@userscript-proxy/core/strings"; + +import { + createScript, + deleteScript, + listScripts, + readScript, + updateScript, +} from "./storage"; + +const NO_FRONTEND_DIR = ""; + +export async function buildApp( + frontendDir: string, + scriptsDir: string, + proxyRestartUrl: string, +) { + const app = Fastify({ + logger: true, + }); + + if (frontendDir !== NO_FRONTEND_DIR) { + await app.register(fastifyStatic, { + root: frontendDir, + wildcard: false, + }); + } + + app.get<{ Reply: HealthStatus }>("/api/health", async (_request, reply) => { + return reply.code(200).send({ ok: true }); + }); + + app.get<{ Reply: Array }>( + "/api/scripts", + async (_request, reply) => + reply.code(200).send(await listScripts(scriptsDir)), + ); + + app.post<{ + Body: CreateScriptRequest; + Reply: + | undefined + | ScriptAlreadyExistsErrorBody + | BadRequestErrorBody + | InternalServerErrorBody; + }>("/api/scripts", async (request, reply) => { + const body: unknown = request.body; + + const decodingResult = decodeWith(CreateScriptRequestCodec, body, false); + + if (decodingResult.tag === "Err") { + return reply.code(400).send({ + badRequestReason: `Invalid request payload: ${decodingResult.error}`, + }); + } + + const { filenameWithoutExtension, content } = decodingResult.value; + + const creationResult = await createScript( + scriptsDir, + filenameWithoutExtension, + content, + ); + + if (creationResult.tag === "Ok") { + return reply.code(201).send(undefined); + } + + switch (creationResult.error.tag) { + case "InvalidName": + return reply.code(400).send({ + badRequestReason: creationResult.error.reason, + }); + + case "AlreadyExists": + return reply.code(409).send({ + existingScriptName: withExtension(filenameWithoutExtension), + }); + + case "CouldNotWrite": + return reply.code(500).send({ + serverErrorReason: creationResult.error.reason, + }); + + default: + assertExhausted(creationResult.error, "script-creation error"); + } + }); + + app.get<{ + Params: { filename: string }; + Reply: + | ScriptDetails + | ScriptNotFoundErrorBody + | BadRequestErrorBody + | InternalServerErrorBody; + }>("/api/scripts/:filename", async (request, reply) => { + const { filename } = request.params; + + if (!isUserscriptFilename(filename)) { + // If nothing else, this should prevent us from accidentally serving a non-userscript file. + return reply.code(400).send({ + badRequestReason: `Invalid userscript filename: ${quote(filename)}`, + }); + } + + const result = await readScript(scriptsDir, filename); + + if (result.tag === "Ok") { + return reply.code(200).send({ scriptContent: result.value }); + } + + switch (result.error.tag) { + case "NotFound": + return reply.code(404).send({ missingScriptName: filename }); + + case "CouldNotRead": + return reply.code(500).send({ + serverErrorReason: result.error.reason, + }); + + default: + assertExhausted(result.error, "script-read error"); + } + }); + + app.put<{ + Params: { filename: string }; + Body: UpdateScriptRequest; + Reply: + | undefined + | ScriptNotFoundErrorBody + | BadRequestErrorBody + | InternalServerErrorBody; + }>("/api/scripts/:filename", async (request, reply) => { + const { filename } = request.params; + + if (!isUserscriptFilename(filename)) { + return reply.code(400).send({ + badRequestReason: `Invalid userscript filename: ${quote(filename)}`, + }); + } + + const body: unknown = request.body; + const decodingResult = decodeWith(UpdateScriptRequestCodec, body, false); + + if (decodingResult.tag === "Err") { + return reply.code(400).send({ + badRequestReason: `Invalid request payload: ${decodingResult.error}`, + }); + } + + const { newScriptContent } = decodingResult.value; + const result = await updateScript(scriptsDir, filename, newScriptContent); + + if (result.tag === "Ok") { + return reply.code(200).send(undefined); + } + + switch (result.error.tag) { + case "NotFound": + return reply.code(404).send({ missingScriptName: filename }); + + case "CouldNotWrite": + return reply.code(500).send({ + serverErrorReason: result.error.reason, + }); + + default: + assertExhausted(result.error, "script-update error"); + } + }); + + app.delete<{ + Params: { filename: string }; + Reply: + | undefined + | ScriptNotFoundErrorBody + | BadRequestErrorBody + | InternalServerErrorBody; + }>("/api/scripts/:filename", async (request, reply) => { + const { filename } = request.params; + + if (!isUserscriptFilename(filename)) { + // If nothing else, this should prevent us from accidentally deleting a non-userscript file. + return reply.code(400).send({ + badRequestReason: `Invalid userscript filename: ${quote(filename)}`, + }); + } + + const result = await deleteScript(scriptsDir, filename); + + if (result.tag === "Ok") { + return reply.code(200).send(undefined); + } + + switch (result.error.tag) { + case "NotFound": + return reply.code(404).send({ missingScriptName: filename }); + + case "CouldNotDelete": + return reply.code(500).send({ + serverErrorReason: result.error.reason, + }); + + default: + assertExhausted(result.error, "script-delete error"); + } + }); + + app.post<{ Reply: HealthStatus }>( + "/api/proxy/restart", + async (_request, reply) => { + const response = await fetch(proxyRestartUrl, { method: "POST" }); + return reply.code(200).send({ ok: response.ok }); + }, + ); + + return app; +} diff --git a/admin-ui/backend/src/environment.ts b/admin-ui/backend/src/environment.ts new file mode 100644 index 0000000..e0a0559 --- /dev/null +++ b/admin-ui/backend/src/environment.ts @@ -0,0 +1,9 @@ +export function getEnvVarOrThrow(envVarName: string): string { + const value = process.env[envVarName]; + + if (value === undefined) { + throw new Error(`Environment variable '${envVarName}' not specified.`); + } + + return value; +} diff --git a/admin-ui/backend/src/index.ts b/admin-ui/backend/src/index.ts new file mode 100644 index 0000000..107f5bd --- /dev/null +++ b/admin-ui/backend/src/index.ts @@ -0,0 +1,23 @@ +import { errorMessageFromCaught } from "@userscript-proxy/core/errors"; + +import { buildApp } from "./app"; +import { getEnvVarOrThrow } from "./environment"; + +const host = getEnvVarOrThrow("BACKEND_HOST"); +const port = Number.parseInt(getEnvVarOrThrow("BACKEND_PORT")); + +async function main(): Promise { + try { + const frontendDir = getEnvVarOrThrow("FRONTEND_DIR"); // Set to the empty string to not serve frontend. + const scriptsDir = getEnvVarOrThrow("SCRIPTS_DIR"); + const proxyRestartUrl = getEnvVarOrThrow("PROXY_RESTART_URL"); + + const app = await buildApp(frontendDir, scriptsDir, proxyRestartUrl); + await app.listen({ host, port }); + } catch (error) { + console.error(errorMessageFromCaught(error)); + process.exit(1); + } +} + +void main(); diff --git a/admin-ui/backend/src/reset.d.ts b/admin-ui/backend/src/reset.d.ts new file mode 100644 index 0000000..a3d4a03 --- /dev/null +++ b/admin-ui/backend/src/reset.d.ts @@ -0,0 +1 @@ +import "@total-typescript/ts-reset"; diff --git a/admin-ui/backend/src/storage.ts b/admin-ui/backend/src/storage.ts new file mode 100644 index 0000000..2359f09 --- /dev/null +++ b/admin-ui/backend/src/storage.ts @@ -0,0 +1,135 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { ScriptSummary } from "@userscript-proxy/core/api/ScriptSummary"; +import { errorMessageFromCaught } from "@userscript-proxy/core/errors"; +import { + isUserscriptFilename, + howToSortFilenames, + validateFilename, + withExtension, +} from "@userscript-proxy/core/files"; +import { Err, Ok, type Result } from "@userscript-proxy/core/results"; + +export async function listScripts( + scriptsDir: string, +): Promise> { + const files = await fs.readdir(scriptsDir, { recursive: true }); + return files + .filter(isUserscriptFilename) + .toSorted(howToSortFilenames) + .map((f) => ({ filename: f })); +} + +type ScriptCreationError = + | { tag: "InvalidName"; reason: string } + | { tag: "AlreadyExists" } + | { tag: "CouldNotWrite"; reason: string }; + +export async function createScript( + scriptsDir: string, + filenameWithoutExtension: string, + content: string, +): Promise> { + const filenameValidationResult = validateFilename(filenameWithoutExtension); + + if (filenameValidationResult.tag === "Err") { + return Err({ tag: "InvalidName", reason: filenameValidationResult.error }); + } + + const filename = withExtension(filenameWithoutExtension); + const filePath = path.join(scriptsDir, filename); + try { + await fs.writeFile(filePath, content, { flag: "wx" }); + return Ok(undefined); + } catch (caught) { + if (isErrnoException(caught) && caught.code === "EEXIST") { + return Err({ tag: "AlreadyExists" }); + } + + return Err({ + tag: "CouldNotWrite", + reason: errorMessageFromCaught(caught), + }); + } +} + +type ScriptReadError = + | { tag: "NotFound" } + | { tag: "CouldNotRead"; reason: string }; + +export async function readScript( + scriptsDir: string, + filename: string, +): Promise> { + const filePath = path.join(scriptsDir, filename); + try { + const content = await fs.readFile(filePath, "utf-8"); + return Ok(content); + } catch (caught) { + if (isErrnoException(caught) && caught.code === "ENOENT") { + return Err({ tag: "NotFound" }); + } + return Err({ tag: "CouldNotRead", reason: errorMessageFromCaught(caught) }); + } +} + +type ScriptUpdateError = + | { tag: "NotFound" } + | { tag: "CouldNotWrite"; reason: string }; + +export async function updateScript( + scriptsDir: string, + filename: string, + content: string, +): Promise> { + const filePath = path.join(scriptsDir, filename); + try { + await fs.stat(filePath); + } catch (caught) { + if (isErrnoException(caught) && caught.code === "ENOENT") { + return Err({ tag: "NotFound" }); + } + return Err({ + tag: "CouldNotWrite", + reason: errorMessageFromCaught(caught), + }); + } + + try { + await fs.writeFile(filePath, content); + return Ok(undefined); + } catch (caught) { + return Err({ + tag: "CouldNotWrite", + reason: errorMessageFromCaught(caught), + }); + } +} + +type ScriptDeleteError = + | { tag: "NotFound" } + | { tag: "CouldNotDelete"; reason: string }; + +export async function deleteScript( + scriptsDir: string, + filename: string, +): Promise> { + const filePath = path.join(scriptsDir, filename); + try { + await fs.unlink(filePath); + return Ok(undefined); + } catch (caught) { + if (isErrnoException(caught) && caught.code === "ENOENT") { + return Err({ tag: "NotFound" }); + } + return Err({ + tag: "CouldNotDelete", + reason: errorMessageFromCaught(caught), + }); + } +} + +function isErrnoException(e: unknown): e is NodeJS.ErrnoException { + return e instanceof Error && "code" in e; +} diff --git a/admin-ui/backend/tsconfig.json b/admin-ui/backend/tsconfig.json new file mode 100644 index 0000000..481d360 --- /dev/null +++ b/admin-ui/backend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "types": ["node"], + }, + "include": ["src"], +} diff --git a/admin-ui/core/package.json b/admin-ui/core/package.json new file mode 100644 index 0000000..ce153ba --- /dev/null +++ b/admin-ui/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@userscript-proxy/core", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "dependencies": { + "tiny-decoders": "^24.0.0" + } +} diff --git a/admin-ui/core/src/api/BadRequestErrorBody.ts b/admin-ui/core/src/api/BadRequestErrorBody.ts new file mode 100644 index 0000000..f4795f8 --- /dev/null +++ b/admin-ui/core/src/api/BadRequestErrorBody.ts @@ -0,0 +1,10 @@ +import * as td from "tiny-decoders"; + +export type BadRequestErrorBody = { + badRequestReason: string; +}; + +export const BadRequestErrorBodyCodec: td.Codec = + td.fields({ + badRequestReason: td.string, + }); diff --git a/admin-ui/core/src/api/CreateScriptRequest.ts b/admin-ui/core/src/api/CreateScriptRequest.ts new file mode 100644 index 0000000..90769d6 --- /dev/null +++ b/admin-ui/core/src/api/CreateScriptRequest.ts @@ -0,0 +1,12 @@ +import * as td from "tiny-decoders"; + +export type CreateScriptRequest = { + filenameWithoutExtension: string; + content: string; +}; + +export const CreateScriptRequestCodec: td.Codec = + td.fields({ + filenameWithoutExtension: td.string, + content: td.string, + }); diff --git a/admin-ui/core/src/api/HealthStatus.ts b/admin-ui/core/src/api/HealthStatus.ts new file mode 100644 index 0000000..6a8b8f7 --- /dev/null +++ b/admin-ui/core/src/api/HealthStatus.ts @@ -0,0 +1 @@ +export type HealthStatus = { ok: boolean }; diff --git a/admin-ui/core/src/api/InternalServerErrorBody.ts b/admin-ui/core/src/api/InternalServerErrorBody.ts new file mode 100644 index 0000000..adf79f6 --- /dev/null +++ b/admin-ui/core/src/api/InternalServerErrorBody.ts @@ -0,0 +1,10 @@ +import * as td from "tiny-decoders"; + +export type InternalServerErrorBody = { + serverErrorReason: string; +}; + +export const InternalServerErrorBodyCodec: td.Codec = + td.fields({ + serverErrorReason: td.string, + }); diff --git a/admin-ui/core/src/api/ScriptAlreadyExistsErrorBody.ts b/admin-ui/core/src/api/ScriptAlreadyExistsErrorBody.ts new file mode 100644 index 0000000..4d49526 --- /dev/null +++ b/admin-ui/core/src/api/ScriptAlreadyExistsErrorBody.ts @@ -0,0 +1,10 @@ +import * as td from "tiny-decoders"; + +export type ScriptAlreadyExistsErrorBody = { + existingScriptName: string; +}; + +export const ScriptAlreadyExistsErrorBodyCodec: td.Codec = + td.fields({ + existingScriptName: td.string, + }); diff --git a/admin-ui/core/src/api/ScriptDetails.ts b/admin-ui/core/src/api/ScriptDetails.ts new file mode 100644 index 0000000..15761b2 --- /dev/null +++ b/admin-ui/core/src/api/ScriptDetails.ts @@ -0,0 +1,9 @@ +import * as td from "tiny-decoders"; + +export type ScriptDetails = { + scriptContent: string; +}; + +export const ScriptDetailsCodec: td.Codec = td.fields({ + scriptContent: td.string, +}); diff --git a/admin-ui/core/src/api/ScriptNotFoundErrorBody.ts b/admin-ui/core/src/api/ScriptNotFoundErrorBody.ts new file mode 100644 index 0000000..e25a31c --- /dev/null +++ b/admin-ui/core/src/api/ScriptNotFoundErrorBody.ts @@ -0,0 +1,10 @@ +import * as td from "tiny-decoders"; + +export type ScriptNotFoundErrorBody = { + missingScriptName: string; +}; + +export const ScriptNotFoundErrorBodyCodec: td.Codec = + td.fields({ + missingScriptName: td.string, + }); diff --git a/admin-ui/core/src/api/ScriptSummary.ts b/admin-ui/core/src/api/ScriptSummary.ts new file mode 100644 index 0000000..4bdadd5 --- /dev/null +++ b/admin-ui/core/src/api/ScriptSummary.ts @@ -0,0 +1,9 @@ +import * as td from "tiny-decoders"; + +export type ScriptSummary = { + filename: string; +}; + +export const ScriptSummaryCodec: td.Codec = td.fields({ + filename: td.string, +}); diff --git a/admin-ui/core/src/api/UpdateScriptRequest.ts b/admin-ui/core/src/api/UpdateScriptRequest.ts new file mode 100644 index 0000000..590ac39 --- /dev/null +++ b/admin-ui/core/src/api/UpdateScriptRequest.ts @@ -0,0 +1,10 @@ +import * as td from "tiny-decoders"; + +export type UpdateScriptRequest = { + newScriptContent: string; +}; + +export const UpdateScriptRequestCodec: td.Codec = + td.fields({ + newScriptContent: td.string, + }); diff --git a/admin-ui/core/src/assertions.ts b/admin-ui/core/src/assertions.ts new file mode 100644 index 0000000..3a9d5fc --- /dev/null +++ b/admin-ui/core/src/assertions.ts @@ -0,0 +1,24 @@ +export function assertExhausted(x: never, description: string): never { + throw new TypeError( + `assertExhausted: ${description} was unexpectedly ${JSON.stringify(x)}`, + ); +} + +export function throwIfNullOrUndefined( + x: T, + descriptionOfIt: DescriptionOrRedundanceHint, +): NonNullable { + if (x == null) { + throw new TypeError( + `throwIfNullOrUndefined: ${descriptionOfIt} was unexpectedly ${JSON.stringify(x)}.`, + ); + } + + return x; +} + +type DescriptionOrRedundanceHint = null extends T + ? string + : undefined extends T + ? string + : `It cannot be null or undefined; this check is redundant.`; diff --git a/admin-ui/core/src/decoding.ts b/admin-ui/core/src/decoding.ts new file mode 100644 index 0000000..00bbc83 --- /dev/null +++ b/admin-ui/core/src/decoding.ts @@ -0,0 +1,24 @@ +import * as td from "tiny-decoders"; + +import { Err, Ok, type Result } from "./results"; + +export function decodeWith( + codec: td.Codec, + input: unknown, + isSensitive: boolean, +): Result { + return toResult(codec.decoder(input), isSensitive); +} + +function toResult( + decoderResult: td.DecoderResult, + isSensitive: boolean, +): Result { + switch (decoderResult.tag) { + case "DecoderError": + return Err(td.format(decoderResult.error, { sensitive: isSensitive })); + + case "Valid": + return Ok(decoderResult.value); + } +} diff --git a/admin-ui/core/src/errors.ts b/admin-ui/core/src/errors.ts new file mode 100644 index 0000000..9904a9e --- /dev/null +++ b/admin-ui/core/src/errors.ts @@ -0,0 +1,12 @@ +export function errorMessageFromCaught(caught: unknown): string { + return caught instanceof Error + ? `${caught.name}: ${caught.message}` + : typeof caught === "string" + ? caught + : String(caught); +} + +export type ErrorInfo = { + uiError: string; + logError: string; +}; diff --git a/admin-ui/core/src/fetching.ts b/admin-ui/core/src/fetching.ts new file mode 100644 index 0000000..dbf295d --- /dev/null +++ b/admin-ui/core/src/fetching.ts @@ -0,0 +1,29 @@ +import * as td from "tiny-decoders"; + +import { decodeWith } from "@userscript-proxy/core/decoding"; +import { errorMessageFromCaught } from "@userscript-proxy/core/errors"; +import { Err, Ok } from "@userscript-proxy/core/results"; +import type { NoRejectPromise } from "@userscript-proxy/core/promises"; + +export async function decodeJsonBody_NoReject( + response: Response, + codec: td.Codec, +): NoRejectPromise< + T, + | `Could not parse response body as JSON: ${string}` + | `Unexpected response body shape: ${string}` +> { + let body: unknown; + try { + body = await response.json(); + } catch (caught) { + return Err( + `Could not parse response body as JSON: ${errorMessageFromCaught(caught)}` as const, + ); + } + const decoded = decodeWith(codec, body, false); + if (decoded.tag === "Ok") { + return Ok(decoded.value); + } + return Err(`Unexpected response body shape: ${decoded.error}` as const); +} diff --git a/admin-ui/core/src/files.ts b/admin-ui/core/src/files.ts new file mode 100644 index 0000000..735b5fe --- /dev/null +++ b/admin-ui/core/src/files.ts @@ -0,0 +1,35 @@ +import { Err, Ok, type Result } from "./results"; + +const USERSCRIPT_EXT = ".user.js"; + +const FILENAME_WITHOUT_EXTENSION_PATTERN = /^[A-Za-z0-9-_]+$/; + +export function isUserscriptFilename( + filename: string, +): filename is `${string}${typeof USERSCRIPT_EXT}` { + return filename.endsWith(USERSCRIPT_EXT); +} + +export function withExtension( + filenameWithoutExtension: T, +): `${T}${typeof USERSCRIPT_EXT}` { + return `${filenameWithoutExtension}${USERSCRIPT_EXT}`; +} + +export function validateFilename( + filenameWithoutExtension: string, +): Result { + if (filenameWithoutExtension.trim() === "") { + return Err("Name cannot be empty."); + } + + if (!FILENAME_WITHOUT_EXTENSION_PATTERN.test(filenameWithoutExtension)) { + return Err(`Name must match ${FILENAME_WITHOUT_EXTENSION_PATTERN}.`); + } + + return Ok(undefined); +} + +export function howToSortFilenames(a: string, b: string) { + return a.localeCompare(b); +} diff --git a/admin-ui/core/src/promises.ts b/admin-ui/core/src/promises.ts new file mode 100644 index 0000000..1263c5f --- /dev/null +++ b/admin-ui/core/src/promises.ts @@ -0,0 +1,6 @@ +import type { Result } from "./results"; + +/** + * A `Promise` that doesn't reject, instead using {@link Result} to represent success/failure. + */ +export type NoRejectPromise = Promise>; diff --git a/admin-ui/core/src/reset.d.ts b/admin-ui/core/src/reset.d.ts new file mode 100644 index 0000000..a3d4a03 --- /dev/null +++ b/admin-ui/core/src/reset.d.ts @@ -0,0 +1 @@ +import "@total-typescript/ts-reset"; diff --git a/admin-ui/core/src/results.ts b/admin-ui/core/src/results.ts new file mode 100644 index 0000000..27cda63 --- /dev/null +++ b/admin-ui/core/src/results.ts @@ -0,0 +1,13 @@ +export type Result = Ok | Err; + +export type Ok = { tag: "Ok"; value: T }; + +export type Err = { tag: "Err"; error: E }; + +export function Ok(value: T): Ok { + return { tag: "Ok", value }; +} + +export function Err(error: E): Err { + return { tag: "Err", error }; +} diff --git a/admin-ui/core/src/strings.ts b/admin-ui/core/src/strings.ts new file mode 100644 index 0000000..bfe696a --- /dev/null +++ b/admin-ui/core/src/strings.ts @@ -0,0 +1,3 @@ +export function quote(text: string): string { + return JSON.stringify(text); +} diff --git a/admin-ui/core/src/userscripts.ts b/admin-ui/core/src/userscripts.ts new file mode 100644 index 0000000..467704f --- /dev/null +++ b/admin-ui/core/src/userscripts.ts @@ -0,0 +1,12 @@ +export function boilerplate(filenameWithoutExtension: string): string { + return `\ +// ==UserScript== +// @name ${filenameWithoutExtension} +// @namespace http://me.example.com +// @match *://example.com/* +// @version 1.0 +// @description A cool userscript. +// ==/UserScript== + +`; +} diff --git a/admin-ui/core/tsconfig.json b/admin-ui/core/tsconfig.json new file mode 100644 index 0000000..1c30a0e --- /dev/null +++ b/admin-ui/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + }, + "include": ["src"], +} diff --git a/admin-ui/eslint.config.js b/admin-ui/eslint.config.js new file mode 100644 index 0000000..c97aca3 --- /dev/null +++ b/admin-ui/eslint.config.js @@ -0,0 +1,51 @@ +import js from "@eslint/js"; +import { defineConfig, globalIgnores } from "eslint/config"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + parserOptions: { + project: [ + "./tsconfig.json", + "./core/tsconfig.json", + "./backend/tsconfig.json", + "./frontend/tsconfig.node.json", + "./frontend/tsconfig.app.json", + ], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/array-type": [ + "error", + { default: "generic", readonly: "generic" }, + ], + "@typescript-eslint/consistent-type-assertions": [ + "error", + { + assertionStyle: "never", + }, + ], + "@typescript-eslint/consistent-type-definitions": ["error", "type"], + "@typescript-eslint/restrict-template-expressions": [ + "error", + { + allowNumber: true, + }, + ], + curly: ["error"], + }, + }, +]); diff --git a/admin-ui/frontend/index.html b/admin-ui/frontend/index.html new file mode 100644 index 0000000..b5d0e2c --- /dev/null +++ b/admin-ui/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Userscript Proxy + + +
+ + + diff --git a/admin-ui/frontend/package.json b/admin-ui/frontend/package.json new file mode 100644 index 0000000..023af27 --- /dev/null +++ b/admin-ui/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "@userscript-proxy/frontend", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --build tsconfig.json && vite build", + "dev": "vite" + }, + "dependencies": { + "@userscript-proxy/core": "*", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tiny-decoders": "^24.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/admin-ui/frontend/public/favicon.ico b/admin-ui/frontend/public/favicon.ico new file mode 100644 index 0000000..72e823b Binary files /dev/null and b/admin-ui/frontend/public/favicon.ico differ diff --git a/admin-ui/frontend/src/App.css b/admin-ui/frontend/src/App.css new file mode 100644 index 0000000..ab81bb3 --- /dev/null +++ b/admin-ui/frontend/src/App.css @@ -0,0 +1,83 @@ +* { + box-sizing: border-box; +} + +body { + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f1f5f9; + color: #1e293b; +} + +.the-app { + min-height: 100vh; + padding: 2rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +#app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +#app-header h1 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +#app-header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +button { + background: #2563eb; + color: #fff; + border: none; + border-radius: 0.375rem; + padding: 0.5rem 0.875rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +button:hover:not(:disabled), +button:focus:not(:disabled) { + background: #1d4ed8; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.button-secondary { + background: transparent; + color: #374151; + border: 1px solid #e2e8f0; +} + +.button-secondary:hover:not(:disabled), +.button-secondary:focus:not(:disabled) { + background: #f1f5f9; +} + +.button-danger { + background: transparent; + color: #ad5353; + border: 1px solid #f0dada; +} + +.button-danger:hover:not(:disabled), +.button-danger:focus:not(:disabled) { + background: #fef2f2; +} diff --git a/admin-ui/frontend/src/App.tsx b/admin-ui/frontend/src/App.tsx new file mode 100644 index 0000000..e1ca106 --- /dev/null +++ b/admin-ui/frontend/src/App.tsx @@ -0,0 +1,23 @@ +import "./App.css"; +import { HealthCheckView } from "./health-check/HealthCheckView"; +import { RestartButton } from "./proxy/RestartButton"; +import { ScriptListView } from "./scripts/ScriptListView"; + +function App() { + return ( +
+
+

Userscript Proxy

+
+ + +
+
+
+ +
+
+ ); +} + +export default App; diff --git a/admin-ui/frontend/src/health-check/HealthCheckView.css b/admin-ui/frontend/src/health-check/HealthCheckView.css new file mode 100644 index 0000000..c16db62 --- /dev/null +++ b/admin-ui/frontend/src/health-check/HealthCheckView.css @@ -0,0 +1,4 @@ +.health-check { + font-size: 0.875rem; + color: #64748b; +} diff --git a/admin-ui/frontend/src/health-check/HealthCheckView.tsx b/admin-ui/frontend/src/health-check/HealthCheckView.tsx new file mode 100644 index 0000000..f74d806 --- /dev/null +++ b/admin-ui/frontend/src/health-check/HealthCheckView.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +import "./HealthCheckView.css"; + +import { + showHealthCheckFailure, + showHealthCheckResponse, +} from "./show-backend-status"; + +export function HealthCheckView() { + const [backendStatus, setBackendStatus] = useState("⏳ Loading …"); + + useEffect(() => { + fetch("/api/health") + .then((response) => { + setBackendStatus(showHealthCheckResponse(response)); + }) + .catch((caught: unknown) => { + setBackendStatus(showHealthCheckFailure(caught)); + }); + }, []); + + return ( + + ); +} diff --git a/admin-ui/frontend/src/health-check/show-backend-status.ts b/admin-ui/frontend/src/health-check/show-backend-status.ts new file mode 100644 index 0000000..cab2a2d --- /dev/null +++ b/admin-ui/frontend/src/health-check/show-backend-status.ts @@ -0,0 +1,13 @@ +import { errorMessageFromCaught } from "@userscript-proxy/core/errors"; + +export function showHealthCheckResponse(response: Response): string { + if (!response.ok) { + return `❌ ${response.status} ${response.statusText}`; + } + + return `✅ OK`; +} + +export function showHealthCheckFailure(caught: unknown): string { + return `❌ ${errorMessageFromCaught(caught)}`; +} diff --git a/admin-ui/frontend/src/index.css b/admin-ui/frontend/src/index.css new file mode 100644 index 0000000..61dbbd0 --- /dev/null +++ b/admin-ui/frontend/src/index.css @@ -0,0 +1,11 @@ +:root { + color: inherit; + background: inherit; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} diff --git a/admin-ui/frontend/src/main.tsx b/admin-ui/frontend/src/main.tsx new file mode 100644 index 0000000..e55c23c --- /dev/null +++ b/admin-ui/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { throwIfNullOrUndefined } from "@userscript-proxy/core/assertions"; + +import "./index.css"; +import App from "./App.tsx"; + +createRoot( + throwIfNullOrUndefined(document.getElementById("root"), "root element"), +).render( + + + , +); diff --git a/admin-ui/frontend/src/proxy/RestartButton.css b/admin-ui/frontend/src/proxy/RestartButton.css new file mode 100644 index 0000000..b7f4613 --- /dev/null +++ b/admin-ui/frontend/src/proxy/RestartButton.css @@ -0,0 +1,3 @@ +.restart-button { + min-width: 9em; /* So it doesn't change size when its content changes. */ +} diff --git a/admin-ui/frontend/src/proxy/RestartButton.tsx b/admin-ui/frontend/src/proxy/RestartButton.tsx new file mode 100644 index 0000000..3d5fe63 --- /dev/null +++ b/admin-ui/frontend/src/proxy/RestartButton.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; + +import { assertExhausted } from "@userscript-proxy/core/assertions"; + +import "./RestartButton.css"; + +type RestartState = + | { tag: "Idle" } + | { tag: "Restarting" } + | { tag: "Done"; ok: boolean }; + +const FEEDBACK_DURATION_MS = 1000; + +export function RestartButton() { + const [restartState, setRestartState] = useState({ + tag: "Idle", + }); + + function handleClick() { + setRestartState({ tag: "Restarting" }); + fetch("/api/proxy/restart", { method: "POST" }) + .then((r) => { + setRestartState({ tag: "Done", ok: r.ok }); + setTimeout(() => { + setRestartState({ tag: "Idle" }); + }, FEEDBACK_DURATION_MS); + }) + .catch(() => { + setRestartState({ tag: "Done", ok: false }); + setTimeout(() => { + setRestartState({ tag: "Idle" }); + }, FEEDBACK_DURATION_MS); + }); + } + + return ( + + ); +} + +function makeButtonLabel(restartState: RestartState) { + switch (restartState.tag) { + case "Idle": + return "Restart proxy"; + + case "Restarting": + return "Restarting …"; + + case "Done": + return restartState.ok ? "✅ Restarted" : "❌ Failed"; + + default: + assertExhausted(restartState, "restart state"); + } +} + +function shouldBeDisabled(restartState: RestartState): boolean { + switch (restartState.tag) { + case "Idle": + return false; + + case "Restarting": + case "Done": + return true; + } +} diff --git a/admin-ui/frontend/src/reset.d.ts b/admin-ui/frontend/src/reset.d.ts new file mode 100644 index 0000000..a3d4a03 --- /dev/null +++ b/admin-ui/frontend/src/reset.d.ts @@ -0,0 +1 @@ +import "@total-typescript/ts-reset"; diff --git a/admin-ui/frontend/src/scripts/AddScriptView.css b/admin-ui/frontend/src/scripts/AddScriptView.css new file mode 100644 index 0000000..f758a5a --- /dev/null +++ b/admin-ui/frontend/src/scripts/AddScriptView.css @@ -0,0 +1,57 @@ +.add-script-filename-row { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.add-script-input { + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-family: monospace; + outline: none; +} + +.add-script-input:focus { + border-color: #2563eb; +} + +.add-script-ext { + font-family: monospace; + font-size: 0.875rem; + color: #64748b; +} + +.add-script-error { + margin: 0; + min-height: 1.2rem; /* Prevents layout shift when error appears/disappears. */ + font-size: 0.875rem; + color: #dc2626; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-dialog { + background: #fff; + border-radius: 0.5rem; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 24rem; +} + +.add-script-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} diff --git a/admin-ui/frontend/src/scripts/AddScriptView.tsx b/admin-ui/frontend/src/scripts/AddScriptView.tsx new file mode 100644 index 0000000..3132c54 --- /dev/null +++ b/admin-ui/frontend/src/scripts/AddScriptView.tsx @@ -0,0 +1,220 @@ +import { useState } from "react"; + +import { BadRequestErrorBodyCodec } from "@userscript-proxy/core/api/BadRequestErrorBody"; +import type { CreateScriptRequest } from "@userscript-proxy/core/api/CreateScriptRequest"; +import { InternalServerErrorBodyCodec } from "@userscript-proxy/core/api/InternalServerErrorBody"; +import { ScriptAlreadyExistsErrorBodyCodec } from "@userscript-proxy/core/api/ScriptAlreadyExistsErrorBody"; +import type { ScriptSummary } from "@userscript-proxy/core/api/ScriptSummary"; +import { assertExhausted } from "@userscript-proxy/core/assertions"; +import { + errorMessageFromCaught, + type ErrorInfo, +} from "@userscript-proxy/core/errors"; +import { decodeJsonBody_NoReject } from "@userscript-proxy/core/fetching"; +import { validateFilename, withExtension } from "@userscript-proxy/core/files"; +import type { NoRejectPromise } from "@userscript-proxy/core/promises"; +import { Err, Ok } from "@userscript-proxy/core/results"; +import { quote } from "@userscript-proxy/core/strings"; +import { boilerplate } from "@userscript-proxy/core/userscripts"; + +import "./AddScriptView.css"; +import { ScriptEditorView } from "./ScriptEditorView"; + +type AddScriptState = + | { + tag: "EnteringFilename"; + filenameWithoutExtension: string; + error: string | null; + } + | { tag: "Editor"; filenameWithoutExtension: string }; + +type Props = { + existingFilenames: ReadonlyArray; + onSaved: (script: ScriptSummary) => void; + onCancelled: () => void; +}; + +export function AddScriptView({ + existingFilenames, + onSaved, + onCancelled, +}: Props) { + const [state, setState] = useState({ + tag: "EnteringFilename", + filenameWithoutExtension: "", + error: null, + }); + + switch (state.tag) { + case "EnteringFilename": + return ( +
+
+
+ { + setState({ + tag: "EnteringFilename", + filenameWithoutExtension: e.target.value, + error: null, + }); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); // Otherwise the Enter press is also interpreted as typing a newline in the edit-mode textarea. + proceedToEditor(state.filenameWithoutExtension); + } + }} + /> + {withExtension("")} +
+

{state.error}

+
+ + +
+
+
+ ); + + case "Editor": + return ( + + saveScript_NoReject(state.filenameWithoutExtension, content) + } + onClose={onCancelled} + /> + ); + + default: + assertExhausted(state, "add script state"); + } + + function proceedToEditor(filenameWithoutExtension: string) { + const validationResult = validateFilename(filenameWithoutExtension); + if (validationResult.tag === "Err") { + setState({ + tag: "EnteringFilename", + filenameWithoutExtension, + error: validationResult.error, + }); + return; + } + + if (existingFilenames.includes(withExtension(filenameWithoutExtension))) { + setState({ + tag: "EnteringFilename", + filenameWithoutExtension, + error: "A script with this name already exists.", + }); + return; + } + + setState({ tag: "Editor", filenameWithoutExtension }); + } + + async function saveScript_NoReject( + filenameWithoutExtension: string, + content: string, + ): NoRejectPromise { + try { + const response = await fetch("/api/scripts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + filenameWithoutExtension, + } satisfies CreateScriptRequest), + }); + + const result = await interpretSaveResponse_NoReject(response); + + if (result.tag === "Ok") { + onSaved({ filename: withExtension(filenameWithoutExtension) }); + } + + return result; + } catch (caught: unknown) { + return Err({ + uiError: "Unexpected error.", + logError: `Unexpected error when saving script: ${errorMessageFromCaught(caught)}`, + }); + } + } +} + +async function interpretSaveResponse_NoReject( + response: Response, +): NoRejectPromise { + if (response.ok) { + return Ok(null); + } + + const logMessagePrefix = `Could not save script. Response status: ${response.status}.`; + + switch (response.status) { + case 400: { + const bodyResult = await decodeJsonBody_NoReject( + response, + BadRequestErrorBodyCodec, + ); + return Err({ + uiError: "Invalid request.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Reason: ${bodyResult.value.badRequestReason}` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + case 409: { + const bodyResult = await decodeJsonBody_NoReject( + response, + ScriptAlreadyExistsErrorBodyCodec, + ); + return Err({ + uiError: "A script with that name already exists.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} A script named ${quote(bodyResult.value.existingScriptName)} already exists.` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + case 500: { + const bodyResult = await decodeJsonBody_NoReject( + response, + InternalServerErrorBodyCodec, + ); + return Err({ + uiError: "Server failed to save script.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Reason: ${bodyResult.value.serverErrorReason}` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + default: + return Err({ + uiError: "Could not save script.", + logError: `${logMessagePrefix} Server responded with ${response.status} ${response.statusText}.`, + }); + } +} diff --git a/admin-ui/frontend/src/scripts/EditScriptView.tsx b/admin-ui/frontend/src/scripts/EditScriptView.tsx new file mode 100644 index 0000000..c0e7efc --- /dev/null +++ b/admin-ui/frontend/src/scripts/EditScriptView.tsx @@ -0,0 +1,263 @@ +import { useEffect, useState } from "react"; + +import { BadRequestErrorBodyCodec } from "@userscript-proxy/core/api/BadRequestErrorBody"; +import { InternalServerErrorBodyCodec } from "@userscript-proxy/core/api/InternalServerErrorBody"; +import { + ScriptDetailsCodec, + type ScriptDetails, +} from "@userscript-proxy/core/api/ScriptDetails"; +import { ScriptNotFoundErrorBodyCodec } from "@userscript-proxy/core/api/ScriptNotFoundErrorBody"; +import type { UpdateScriptRequest } from "@userscript-proxy/core/api/UpdateScriptRequest"; +import { assertExhausted } from "@userscript-proxy/core/assertions"; +import { + errorMessageFromCaught, + type ErrorInfo, +} from "@userscript-proxy/core/errors"; +import { decodeJsonBody_NoReject } from "@userscript-proxy/core/fetching"; +import type { NoRejectPromise } from "@userscript-proxy/core/promises"; +import { Err, Ok, type Result } from "@userscript-proxy/core/results"; +import { quote } from "@userscript-proxy/core/strings"; + +import { ScriptEditorView } from "./ScriptEditorView"; + +type EditScriptState = + | { tag: "Loading" } + | { tag: "Loaded"; content: string } + | { tag: "CouldNotLoad"; error: string }; + +type Props = { + filename: string; + onSaved: () => void; + onCancelled: () => void; +}; + +export function EditScriptView({ filename, onSaved, onCancelled }: Props) { + const [state, setState] = useState({ tag: "Loading" }); + + useEffect(() => { + void fetch(`/api/scripts/${encodeURIComponent(filename)}`) + .then((response) => interpretLoadResponse_NoReject(response)) + .then((result) => { + switch (result.tag) { + case "Ok": + setState({ tag: "Loaded", content: result.value.scriptContent }); + break; + + case "Err": + console.error(result.error.logError); + setState({ tag: "CouldNotLoad", error: result.error.uiError }); + break; + + default: + assertExhausted(result, "load-script response interpretation"); + } + }) + .catch((caught: unknown) => { + setState({ + tag: "CouldNotLoad", + error: `Unexpected error: ${errorMessageFromCaught(caught)}`, + }); + }); + }, [filename]); + + switch (state.tag) { + case "Loading": + return ( +
+

Loading {filename} …

+
+ ); + + case "CouldNotLoad": + return ( +
+

+ Could not load {filename}. Reason: +

{state.error}
+

+ +
+ ); + + case "Loaded": + return ( + update_NoReject(filename, content)} + onClose={onCancelled} + /> + ); + + default: + assertExhausted(state, "edit-script state"); + } + + async function update_NoReject( + filenameToSave: string, + content: string, + ): NoRejectPromise { + try { + const response = await fetch( + `/api/scripts/${encodeURIComponent(filenameToSave)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + newScriptContent: content, + } satisfies UpdateScriptRequest), + }, + ); + const result = await interpretUpdateResponse_NoReject(response); + if (result.tag === "Ok") { + onSaved(); + } + return result; + } catch (caught: unknown) { + const errorMsg = errorMessageFromCaught(caught); + return Err({ + uiError: "Unexpected error.", + logError: `Unexpected error when updating script: ${errorMsg}`, + }); + } + } +} + +async function interpretLoadResponse_NoReject( + response: Response, +): Promise> { + if (response.ok) { + const decoded = await decodeJsonBody_NoReject(response, ScriptDetailsCodec); + + if (decoded.tag === "Err") { + return Err({ + uiError: "Could not load script.", + logError: `Could not load script. Reason: ${decoded.error}`, + }); + } + + return Ok(decoded.value); + } + + const logMessagePrefix = + `Could not load script. Response status: ${response.status}.` as const; + + switch (response.status) { + case 400: { + const bodyResult = await decodeJsonBody_NoReject( + response, + BadRequestErrorBodyCodec, + ); + return Err({ + uiError: "Invalid request.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Reason: ${bodyResult.value.badRequestReason}` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + case 404: { + const bodyResult = await decodeJsonBody_NoReject( + response, + ScriptNotFoundErrorBodyCodec, + ); + return Err({ + uiError: + bodyResult.tag === "Ok" + ? `Script ${quote(bodyResult.value.missingScriptName)} not found.` + : "Script not found.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Script ${quote(bodyResult.value.missingScriptName)} not found.` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + case 500: { + const bodyResult = await decodeJsonBody_NoReject( + response, + InternalServerErrorBodyCodec, + ); + return Err({ + uiError: "Server failed to load script.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Reason: ${bodyResult.value.serverErrorReason}` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + default: + return Err({ + uiError: "Could not load script.", + logError: `${logMessagePrefix} Server responded with ${response.status} ${response.statusText}.`, + }); + } +} + +async function interpretUpdateResponse_NoReject( + response: Response, +): NoRejectPromise { + if (response.ok) { + return Ok(null); + } + + const logMessagePrefix = + `Could not update script. Response status: ${response.status}.` as const; + + switch (response.status) { + case 400: { + const bodyResult = await decodeJsonBody_NoReject( + response, + BadRequestErrorBodyCodec, + ); + return Err({ + uiError: "Invalid request.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Reason: ${bodyResult.value.badRequestReason}` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + case 404: { + const bodyResult = await decodeJsonBody_NoReject( + response, + ScriptNotFoundErrorBodyCodec, + ); + return Err({ + uiError: + bodyResult.tag === "Ok" + ? `Script ${quote(bodyResult.value.missingScriptName)} not found on server.` + : "Script not found on server.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Filename: ${quote(bodyResult.value.missingScriptName)}` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + case 500: { + const bodyResult = await decodeJsonBody_NoReject( + response, + InternalServerErrorBodyCodec, + ); + return Err({ + uiError: "Server failed to update script.", + logError: + bodyResult.tag === "Ok" + ? `${logMessagePrefix} Reason: ${bodyResult.value.serverErrorReason}` + : `${logMessagePrefix} ${bodyResult.error}`, + }); + } + + default: + return Err({ + uiError: "Could not update script.", + logError: `${logMessagePrefix} Server responded with ${response.status} ${response.statusText}.`, + }); + } +} diff --git a/admin-ui/frontend/src/scripts/ScriptEditorView.css b/admin-ui/frontend/src/scripts/ScriptEditorView.css new file mode 100644 index 0000000..06e0e96 --- /dev/null +++ b/admin-ui/frontend/src/scripts/ScriptEditorView.css @@ -0,0 +1,51 @@ +.modal-panel { + position: fixed; + inset: 0; + background: #fff; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + z-index: 100; +} + +.script-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.script-editor-filename { + margin: 0; + font-family: monospace; + font-size: 0.875rem; + color: #64748b; +} + +.script-editor-error { + margin: 0; + font-size: 0.875rem; + color: #dc2626; +} + +.script-editor-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.script-editor-textarea { + flex: 1; + font-family: monospace; + font-size: 0.875rem; + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + padding: 0.75rem; + resize: none; + outline: none; +} + +.script-editor-textarea:focus { + border-color: #2563eb; +} diff --git a/admin-ui/frontend/src/scripts/ScriptEditorView.tsx b/admin-ui/frontend/src/scripts/ScriptEditorView.tsx new file mode 100644 index 0000000..057e580 --- /dev/null +++ b/admin-ui/frontend/src/scripts/ScriptEditorView.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; + +import { assertExhausted } from "@userscript-proxy/core/assertions"; +import type { ErrorInfo } from "@userscript-proxy/core/errors"; +import type { NoRejectPromise } from "@userscript-proxy/core/promises"; + +import "./ScriptEditorView.css"; + +type ScriptEditorState = + | { tag: "Editing"; content: string; error: string | null } + | { tag: "Saving"; content: string }; + +type Props = { + filename: string; + initialContent: string; + onSave_NoReject: (content: string) => NoRejectPromise; + onClose: () => void; +}; + +export function ScriptEditorView({ + filename, + initialContent, + onSave_NoReject, + onClose, +}: Props) { + const [state, setState] = useState({ + tag: "Editing", + content: initialContent, + error: null, + }); + + const textareaRef = useRef(null); + + useEffect(() => { + const textarea = textareaRef.current; + if (textarea !== null) { + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + } + }, []); + + const isSaving = state.tag === "Saving"; + const content = state.content; + const error = state.tag === "Editing" ? state.error : null; + + return ( +
+
+

{filename}

+ {error !== null &&

{error}

} +
+ + +
+
+