diff --git a/Dockerfile b/Dockerfile index cee7271..cdad17a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,27 @@ -FROM python:3.7-alpine as base +FROM python:3.9.23-slim AS base + +FROM base AS builder + +WORKDIR /builddir -RUN apk add -U --no-cache \ - gcc \ - build-base \ - linux-headers \ - ca-certificates \ - python3-dev \ - libffi-dev \ - openssl-dev \ - libxslt-dev -WORKDIR /app -RUN pip install mitmproxy COPY requirements.txt . -RUN pip install -r requirements.txt -COPY . ./ +# 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 + +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 default-rules default-rules +COPY default-userscripts default-userscripts + EXPOSE 8080 -CMD [ "./launcher.py", "--recursive", "--ignore", "ignore.txt" ] +ENTRYPOINT [ "python", "-u", "src/restartable_launcher.py" ] diff --git a/Dockerfile.transparent b/Dockerfile.transparent deleted file mode 100644 index 0d0d770..0000000 --- a/Dockerfile.transparent +++ /dev/null @@ -1,18 +0,0 @@ -FROM python:3.7-alpine as base - -RUN apk add -U --no-cache \ - gcc \ - build-base \ - linux-headers \ - ca-certificates \ - python3-dev \ - libffi-dev \ - openssl-dev \ - libxslt-dev -WORKDIR /app -RUN pip install mitmproxy -COPY requirements.txt . -RUN pip install -r requirements.txt -COPY . ./ -EXPOSE 8080 -CMD [ "./launcher.py", "--transparent", "--recursive" ] 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/LICENSE b/LICENSE index 3770507..9811b04 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Simon Alling +Copyright (c) 2017–2020 Simon Alling Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c838a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +DEFAULT_TAG = latest +TAG ?= $(DEFAULT_TAG) + +FILE_WITH_VERSION = src/modules/constants.py +DOCKER_USER = alling +DOCKER_REPO = userscript-proxy +# Can be anything: +CA_VOLUME = mitmproxy-ca +# Needs to match where mitmproxy stores its CA: +CA_DIR = /root/.mitmproxy + +.PHONY : all +all: image + +image: + docker build -t $(DOCKER_USER)/$(DOCKER_REPO):$(TAG) . + +install: image + docker image inspect $(DOCKER_USER)/$(DOCKER_REPO):$(TAG) > /dev/null + +release: +ifneq "$(shell git status --porcelain)" "" + $(error Working directory not clean) +endif +ifeq "$(TAG)" "$(DEFAULT_TAG)" + $(error Please specify a version (e.g. TAG="1.2.3")) +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) 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 -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 14b962e..511a23d 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,135 @@ Browser extensions on iOS, Android and pretty much any other web browsing device. No jailbreak/root required. -Userscript Proxy is built around [mitmproxy][mitmproxy] and acts as a MITM, injecting matching userscripts into web pages as they flow through it. +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. -## Getting started - -```bash -$ docker build -t userscript-proxy:latest . -$ docker run -p 8080:8080 userscript-proxy +# 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: + + * **You should run the proxy on your own server**, because it can read and modify all traffic sent through it. + * **You should not expose the proxy to incoming connections from the Internet**, because then anyone can connect to it and use your Internet connection for whatever they want. + In practice, this means that you should _not_ add a port-forward for Userscript Proxy in your router. + (Browsing the web via the proxy uses only outgoing connections, which is fine.) + +## Starting the proxy + +1. Make sure you have [Docker](https://www.docker.com) installed. + +1. Start 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. + +1. Check that the proxy is working. + There are two ways of doing this: + + * On the command line (in a new terminal): + + ``` + curl --proxy localhost:8080 http://example.com + ``` + + The output should contain a ``), never linked (``). Useful to test new userscript features without having to re-upload the userscript and clear browser cache. -### `--list-injected`, `-l` +## `--list-injected`, `-l` Insert an HTML comment in each page specifying which userscripts (if any) were injected. -### `--port PORT`, `-p PORT` +## `--no-default-rules` + +Skip built-in default rules, which are otherwise automatically applied so that common apps like App Store and Facebook Messenger work out of the box. + +## `--no-default-userscripts` + +Skip loading built-in default userscripts intended for sanity checks and similar purposes, e.g. Example Userscript. + +## `--port PORT`, `-p PORT` Make mitmproxy listen to TCP port `PORT`. Defaults to `8080`. -### `--query-param-to-disable PARAM`, `-q PARAM` +**Note:** Be careful when running Userscript Proxy in Docker! If you want to use e.g. port 1337 on the host machine, do this instead: + +```bash +docker run -t --rm --name userscript-proxy -p 1337:8080 alling/userscript-proxy +``` + +If you really want Userscript Proxy to use a certain port _inside_ the Docker container, e.g. 5555, don't forget to publish that port: + +```bash +docker run -t --rm --name userscript-proxy -p 1337:5555 alling/userscript-proxy -p 5555 +``` + +Or you can let the Docker container be a part of the host's network: + +```bash +docker run -t --rm --network host --name userscript-proxy alling/userscript-proxy -p 5555 +``` + +## `--query-param-to-disable PARAM`, `-q PARAM` Disable userscripts when the request URL contains `PARAM` as a query parameter. For example, use `-q foo` to disable userscripts for `http://example.com?foo`. Defaults to `nouserscripts`. -### `--recursive`, `-r` +## `--rules FILE` -Recurse into directories when looking for userscripts. +Take ignore or intercept rules from `FILE`, which can be a glob pattern matching multiple files. +By default, matching traffic is ignored; use `--intercept` to invert this behavior. +See examples above. -### `--transparent`, `-t` +## `--transparent`, `-t` Run mitmproxy in [transparent mode][transparent-mode]. Useful if you cannot set a proxy in the client, e.g. when using OpenVPN Connect on Android to connect to a VPN server on the network where your proxy is running. @@ -157,14 +309,24 @@ In such cases, you have to route traffic from the client to the proxy at the net **NOTE:** In transparent mode, ignore/intercept rules based on hostname (rather than IP address) may not work, because mitmproxy may not be able to see the hostname of responses without intercepting them. -### `--userscripts DIR`, `-u DIR` +## `--userscripts-dir DIR`, `-u DIR` Load userscripts from directory `DIR`. -Defaults to `userscripts`. + + +# Contribute + +How to build and run from source: + +``` +git clone https://github.com/SimonAlling/userscript-proxy +cd userscript-proxy +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}

} +
+ + +
+
+