diff --git a/.gitignore b/.gitignore index 4657ebc..f586d15 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules/ .env data/config.json data/host.key +data/portfolio.sqlite* +data/dashboard-token *.log diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..48bd824 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,138 @@ +# SSH Portfolio Development Plan + +This roadmap is based on the requested product: an anonymous, self-hostable SSH portfolio with modular pages, optional owner access, chat, notifications, live presence, and an owner-challenge Pong game. + +## Current state + +The repository already has a working MVP with: + +- Anonymous SSH `none` authentication. +- Optional signed public-key authentication for administrator mode. +- Modular page files in `src/pages/` and command files in `src/commands/`. +- Configurable persona, colors, ports, and page content. +- Full-screen terminal page rendering, prompt editing, history, paste handling, and resize-aware headers. +- Live connected-user count. +- Visitor-to-visitor/owner chat broadcast. +- Owner presence based on an active admin SSH session. +- A localhost notification companion using newline-delimited JSON. +- PM2 deployment support. +- SQLite persistence for a moderated bulletin board and server-owned game leaderboards. +- ASCII Flappy and Pong commands, including bot play and a simple online Pong waiting queue. + +The following items are incomplete, partial, or not implemented yet. + +## Phase 1 — MVP hardening + +Priority: high + +### Configuration and page authoring + +- [x] Make page modules the single source of truth; clearly label the legacy `config.pages` content fallback. +- [x] Add a page template such as `src/pages/_template.js` with comments and examples. +- [x] Add page metadata: navigation order, aliases, visibility, required role, and short description. +- Add an optional `data/`-backed content mode for users who prefer editing JSON/Markdown instead of JavaScript. +- [x] Validate configuration at startup and provide actionable errors for invalid colors, ports, keys, and page modules. + +### Terminal correctness + +- Add automated tests for ANSI output, narrow terminals, resize events, history, paste, backspace, and simultaneous notifications. +- Handle terminal capability differences: color support, Unicode box-drawing fallback, and non-PTY `ssh host command` execution. +- Improve wide-character/emoji width calculation so boxes remain aligned with international text. +- Add graceful handling for disconnects, half-open sockets, and server shutdown. + +### Security and abuse controls + +- Add per-IP connection limits, maximum concurrent sessions, idle timeouts, and chat rate limits. +- Bound input length and notification payload size. +- Add optional allow/deny lists and a configurable maintenance/read-only mode. +- Keep anonymous access enabled by default, but document isolation with a dedicated OS user/container. +- Add structured audit logs for admin authentication, admin commands, announcements, and rejected connections. + +## Phase 2 — Owner/admin experience + +Priority: high + +- Add an admin home/dashboard page showing connected users, owner presence, notification-client status, uptime, and recent activity. +- Add persistent admin commands for editing persona fields, themes, and page content with confirmation prompts. +- Add `preview ` and `reload` flows that preserve the current session cleanly. +- Add safe file writes with atomic replace, backups, and validation before applying edits. +- Add an admin-only user/session management view: list sessions, disconnect a session, mute a visitor, and ban an IP temporarily. +- Support multiple admin keys with labels and optional per-key roles. +- Add a documented `admin` page/command extension point for custom owner tools. + +## Phase 3 — Chat and notification system + +Priority: high + +### SSH chat + +- Add nicknames that are sanitized and scoped to each session. +- Add `/who`, `/me`, `/mute`, `/unmute`, and owner-direct-message semantics. +- Add moderation hooks, profanity filtering, message length limits, cooldowns, and a bounded in-memory history. +- Show clear owner availability and delivery status without claiming a message was read. + +### Companion client + +- Add reconnect/backoff and graceful shutdown. +- Add OS notifications through adapters for Linux (`notify-send`), macOS, and Windows. +- Add a small configuration file for server address, forwarded port, notification preferences, and filtering. +- Authenticate the notification socket with a local secret or Unix socket option. +- Add optional reply support so the owner can answer from the companion client. +- Document SSH port forwarding and remote-owner deployment. + +## Phase 4 — Pong owner challenge + +Priority: medium, bonus feature + +- Add a modular `pong` command/page and a small terminal game engine independent of SSH transport. +- Only offer “challenge owner” when an admin session is online; otherwise offer practice mode against an AI. +- Create a matchmaking state machine: waiting, accepted, active, finished, abandoned, and timed out. +- Render the board using the negotiated terminal width/height and provide keyboard controls plus help text. +- Prevent game traffic from corrupting chat or prompt redraws. +- Add owner accept/decline controls and a configurable challenge cooldown. +- Add scores, best-of rounds, spectator mode, and optional persistent leaderboards. +- Test game state deterministically without requiring an SSH connection. + +## Phase 5 — Extensibility and “cool stuff” + +Priority: medium/low + +- Replace manual registries with a documented plugin loader while retaining explicit imports for predictable deployments. +- Add page transitions, an optional animated home banner, and a no-animation accessibility setting. +- Add portfolio utilities: downloadable resume, project detail pages, skills timeline, now page, guestbook, and RSS/Atom feed display. +- Add external integrations through opt-in adapters: GitHub projects, blog posts, uptime, weather, and currently-playing media. +- Add a tiny read-only HTTP status/health endpoint for monitoring. +- Add localization support and configurable date/time formatting. +- Add themes as reusable palettes, including high-contrast and monochrome presets. +- Add easter eggs and safe mini-games that do not require owner presence. + +## Phase 6 — Packaging and operations + +Priority: medium + +- Add a Dockerfile and Compose example with a non-root runtime user and persistent data volume. +- [x] Add a PM2 ecosystem file with environment overrides, log paths, watch mode, and startup instructions. +- Add systemd and OpenRC examples for users who do not use PM2. +- Add health checks, log rotation guidance, and graceful zero-downtime restart documentation. +- Add CI for syntax checks, unit tests, packaging, and a smoke test that connects with an SSH client. +- Add release automation and a changelog. +- Add migration/versioning for `data/config.json` as the configuration schema evolves. + +## Suggested implementation order + +1. Configuration validation, page template, tests, and terminal edge-case fixes. +2. Abuse controls and audit logging before public deployment. +3. Admin dashboard and persistent editing tools. +4. Chat moderation, notification reconnects, and OS notification adapters. +5. Pong engine and owner challenge flow. +6. Plugins, integrations, packaging, and cosmetic extras. + +## Definition of a production-ready release + +- A fresh install can be configured without editing transport code. +- Anonymous visitors can browse pages safely from a standard SSH client. +- An owner can authenticate with a key without making authentication mandatory for visitors. +- Admin changes are validated, persisted safely, and recoverable from backups. +- Chat and notifications are rate-limited, observable, and resilient to disconnects. +- Pong cannot crash or corrupt other sessions. +- Docker/PM2/system-service deployment paths are documented and tested. diff --git a/README.md b/README.md index f648e0f..1fb9877 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,18 @@ npm start The first run creates `data/config.json` from [`config.example.json`](config.example.json). Edit persona, pages, colors, ports, and admin keys, then use `reload` as an admin or restart the server. Pages are content-only; actions are registered in `src/commands.js`, so adding a command does not require changing the SSH transport. +## PM2 deployment + +The included [`ecosystem.config.cjs`](ecosystem.config.cjs) watches `src/` and automatically restarts the SSH server after code changes. Runtime data, SQLite files, dependencies, tests, and logs are ignored to prevent restart loops. + +```sh +mkdir -p logs +pm2 start ecosystem.config.cjs +pm2 save +``` + +Use `pm2 logs ssh-portfolio` to inspect output. Set `PORT` or `NOTIFICATION_PORT` when starting PM2 if the defaults are not suitable, for example `PORT=2222 NOTIFICATION_PORT=7777 pm2 start ecosystem.config.cjs`. + ## Authentication model Anonymous `none` authentication is intentionally accepted for visitors. Add one or more base64 public-key blobs to `adminPublicKeys` to enable administrator mode; key authentication is optional and never required. Admins get `reload`, `theme`, and `announce` commands. @@ -25,13 +37,41 @@ Because this is an intentionally open service, run it in a container or restrict Run `npm run notify` on the owner machine (or use `NOTIFICATION_PORT`) to receive chat and announcement notifications over a localhost-only socket. The protocol is newline-delimited JSON, so it is straightforward to replace with a desktop/mobile notification client. For a separate owner machine, forward the port with `ssh -N -L 7777:127.0.0.1:7777 `. +## SSH monitoring dashboard + +The server also starts a small, localhost-only web dashboard (default `http://127.0.0.1:3000`). On first start it creates a random bearer token in `data/dashboard-token` (mode 0600) and prints a tokenized URL to the server log. Set `DASHBOARD_PORT`, `DASHBOARD_TOKEN`, or the `dashboard.host`, `dashboard.port`, and `dashboard.tokenFile` config fields to customize it. Keep the dashboard bound to localhost and access it through an SSH tunnel when needed, for example `ssh -N -L 3000:127.0.0.1:3000 `. + +The dashboard shows active/recent connections, source IPs, SSH client identifiers, and recorded commands. An administrator can close a connection, ban its IP (which also closes matching active connections), or remove a ban. Bans and audit history are stored in SQLite; command history is retained for operational monitoring, so protect the database and dashboard token. + +## Bulletin board and SQLite + +Persistent data is stored in `data/portfolio.sqlite` by default using SQLite in WAL mode. The server creates `board_posts` and `leaderboard_scores` automatically. [`src/database.js`](src/database.js) provides small parameterized methods for reading/writing both tables; use those methods for extensions instead of building SQL from visitor input. + +Bulletin commands: + +- `board` or `board list` — read the latest notes. +- `board post ` — leave a note. +- `board delete ` — delete a note as an authenticated administrator. + +Posts are limited by character count and UTF-8 byte size. Control characters, terminal escapes, pictographs, and excessive combining marks are rejected. IP addresses are stored only as SHA-256 hashes and are subject to a cooldown and daily posting limit. Configure these limits with `board.maxCharacters`, `board.cooldownSeconds`, and `board.dailyLimit`. + +## Games + +- `flappy` starts a live game; press Space to flap and `q` to quit. +- `pong` or `pong bot` starts a live bot match; use the Up/Down arrow keys and `q` to quit. +- `pong online` waits for or joins another connected visitor. Both players use the Up/Down arrow keys. +- `leaderboard flappy` and `leaderboard pong` show server-recorded scores. + +Both games are ASCII-only and calculate scores on the server; visitors cannot submit leaderboard values directly. + ## Extending -Pages and commands are source modules: +Pages and commands are source modules. Page metadata (`name`, `title`, optional `order`, `aliases`, `visible`, `requiredRole`, and `description`) lives in the module and is authoritative: - `src/pages/home.js`, `about.js`, `projects.js`, and `links.js` are the premade editable pages. -- `src/pages/index.js` is the page registry. Add an import and registry entry when creating a new page; its filename becomes the SSH command. +- `src/pages/index.js` is the page registry. Add an import and registry entry when creating a new page; its exported `name` becomes the SSH command. Aliases are also accepted. - `src/commands/` contains the premade command modules (`chat`, `contact`, `announce`, and so on). +- Each game is self-contained in its command module: `src/commands/flappy.js` owns Flappy physics/live state, and `src/commands/pong.js` owns Pong physics/matchmaking/live state. The shared [`src/database.js`](src/database.js) is intentionally separate because the bulletin board and both games persist data through it. - `src/commands.js` is the command registry. Add an import there to expose a new command. -Each page exports `name`, `title`, and `render({ config, ctx, api })`. The renderer returns terminal text, so you can build extensive custom pages while inheriting the shared header, theme, and prompt. Pong is intentionally left as the next module: it can use the same session registry and owner-online check. +Each page exports `name`, `title`, and `render({ config, ctx, api })`. The renderer returns terminal text, so you can build extensive custom pages while inheriting the shared header, theme, and prompt. The `config.pages` entries in the generated JSON are retained only as a legacy content fallback for the premade projects/links pages; titles and navigation metadata come from modules. diff --git a/config.example.json b/config.example.json index 8c5077c..f19fbd4 100644 --- a/config.example.json +++ b/config.example.json @@ -7,5 +7,9 @@ "links": { "title": "Links", "lines": ["GitHub: https://github.com/yourname", "Website: https://example.com"] } }, "adminPublicKeys": [], + "databasePath": "data/portfolio.sqlite", + "board": { "maxCharacters": 180, "cooldownSeconds": 60, "dailyLimit": 5 }, + "limits": { "maxInputLength": 1024 }, + "dashboard": { "host": "127.0.0.1", "port": 3000, "tokenFile": "data/dashboard-token" }, "host": { "port": 2222, "hostKeyPath": "data/host.key", "notificationPort": 7777 } } diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..a6f10ca --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,18 @@ +module.exports = { + apps: [{ + name: 'ssh-portfolio', + script: './src/server.js', + cwd: __dirname, + interpreter: 'node', + watch: ['src'], + watch_delay: 1000, + ignore_watch: ['node_modules', 'data', 'logs', 'test', '*.sqlite*'], + autorestart: true, + time: true, + out_file: './logs/ssh-portfolio.out.log', + error_file: './logs/ssh-portfolio.error.log', + env: { + NODE_ENV: 'production' + } + }] +}; diff --git a/package-lock.json b/package-lock.json index d7779d4..bfe6bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "ssh-portfolio", "version": "0.1.0", "dependencies": { + "better-sqlite3": "^11.10.0", "ssh2": "^1.16.0" }, "bin": { @@ -27,6 +28,26 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -36,6 +57,61 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -45,6 +121,12 @@ "node": ">=10.0.0" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -59,6 +141,134 @@ "node": ">=10.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/nan": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", @@ -66,12 +276,182 @@ "license": "MIT", "optional": true }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -89,11 +469,81 @@ "nan": "^2.23.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/package.json b/package.json index 413f35e..ffafc7f 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,21 @@ "description": "A customizable, public SSH portfolio for your online persona", "type": "module", "main": "src/server.js", - "bin": { "ssh-portfolio": "src/server.js", "ssh-portfolio-notify": "src/notify-client.js" }, - "scripts": { "start": "node src/server.js", "notify": "node src/notify-client.js", "check": "node --check src/server.js && node --check src/notify-client.js" }, - "engines": { "node": ">=20" }, - "dependencies": { "ssh2": "^1.16.0" } + "bin": { + "ssh-portfolio": "src/server.js", + "ssh-portfolio-notify": "src/notify-client.js" + }, + "scripts": { + "start": "node src/server.js", + "notify": "node src/notify-client.js", + "check": "node --check src/server.js && node --check src/notify-client.js", + "test": "node --test" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "better-sqlite3": "^11.10.0", + "ssh2": "^1.16.0" + } } diff --git a/src/commands.js b/src/commands.js index 4397280..04924ea 100644 --- a/src/commands.js +++ b/src/commands.js @@ -9,19 +9,25 @@ import * as quit from './commands/quit.js'; import * as reload from './commands/reload.js'; import * as theme from './commands/theme.js'; import * as announce from './commands/announce.js'; +import * as board from './commands/board.js'; +import * as leaderboard from './commands/leaderboard.js'; +import * as flappy from './commands/flappy.js'; +import * as pong from './commands/pong.js'; import { pageMap } from './pages/index.js'; // Add a command module import here to make a new command available. -const commands = [home, help, pages, open, contact, chat, clearCommand, quit, reload, theme, announce]; +const commands = [home, help, pages, open, contact, chat, board, leaderboard, flappy, pong, clearCommand, quit, reload, theme, announce]; export function createCommandRouter(api) { const commandMap = new Map(commands.map(command => [command.name, command])); const runPage = (ctx, name) => { const page = pageMap.get(name); + if (page?.requiredRole === 'admin' && !api.isAdmin(ctx)) return `No page named “${name}”. Try: pages`; return page ? page.render({ ...api, api, ctx }) : `No page named “${name}”. Try: pages`; }; return { home: ctx => runPage(ctx, 'home'), + page: name => pageMap.get(name), execute(ctx, input) { const [name, ...rest] = input.trim().split(/\s+/); const arg = rest.join(' '); const command = commandMap.get(name || 'home') || pageMap.get(name); diff --git a/src/commands/board.js b/src/commands/board.js new file mode 100644 index 0000000..5408acb --- /dev/null +++ b/src/commands/board.js @@ -0,0 +1,29 @@ +import { safeName, validatePost } from '../content-policy.js'; + +export const name = 'board'; +export const description = 'Read or post a short visitor note'; + +function show(db) { + const posts = db.listBoardPosts(12); + if (!posts.length) return 'The bulletin board is empty. Be the first: board post '; + return posts.map(post => `#${post.id} ${safeName(post.author)}: ${post.body}`).join('\n') + '\n\nPost with: board post '; +} + +export function run({ arg, ctx, api }) { + if (!arg || arg === 'list') return show(api.db); + if (arg.startsWith('delete ')) { + if (!api.isAdmin(ctx)) return 'Usage: board [list] | board post '; + const id = Number(arg.slice(7)); + if (!Number.isSafeInteger(id) || id < 1) return 'Usage: board delete '; + return api.db.deleteBoardPost(id) ? `Deleted bulletin post #${id}.` : `No bulletin post #${id}.`; + } + if (!arg.startsWith('post ')) return 'Usage: board [list] | board post '; + const checked = validatePost(arg.slice(5), api.config.board?.maxCharacters || 180); + if (!checked.ok) return checked.reason; + const result = api.db.addBoardPost({ + author: safeName(ctx.username), body: checked.value, ip: ctx.ip, + cooldownSeconds: api.config.board?.cooldownSeconds || 60, + dailyLimit: api.config.board?.dailyLimit || 5 + }); + return result.ok ? `Posted note #${result.id}.\n\n${show(api.db)}` : result.reason; +} diff --git a/src/commands/flappy.js b/src/commands/flappy.js new file mode 100644 index 0000000..9036aab --- /dev/null +++ b/src/commands/flappy.js @@ -0,0 +1,70 @@ +import { safeName } from '../content-policy.js'; + +export const name = 'flappy'; +export const description = 'Play ASCII Flappy and save scores'; + +function grid(width, height) { return Array.from({ length: height }, () => Array(width).fill(' ')); } +function frame(cells) { return `+${'-'.repeat(cells[0].length)}+\n${cells.map(row => `|${row.join('')}|`).join('\n')}\n+${'-'.repeat(cells[0].length)}+`; } + +export class FlappyGame { + constructor(random = Math.random) { this.random = random; this.width = 30; this.height = 12; this.reset(); } + reset() { this.y = 5; this.velocity = 0; this.score = 0; this.over = false; this.recorded = false; this.pipes = [{ x: 22, gap: this.newGap() }]; } + newGap() { return 2 + Math.floor(this.random() * (this.height - 6)); } + tick(flap = false) { + if (this.over) return; + this.velocity = flap ? -0.65 : Math.min(0.7, this.velocity + 0.16); this.y += this.velocity; + for (const pipe of this.pipes) pipe.x--; + if (this.pipes[0].x < -1) this.pipes.shift(); + const last = this.pipes.at(-1); + if (last.x < 17) this.pipes.push({ x: this.width - 1, gap: this.newGap() }); + for (const pipe of this.pipes) { + if (pipe.x === 4) this.score++; + if ((pipe.x === 4 || pipe.x === 5) && (this.y < pipe.gap || this.y > pipe.gap + 3)) this.over = true; + } + if (this.y < 0 || this.y >= this.height) this.over = true; + } + render() { + const cells = grid(this.width, this.height); + for (const pipe of this.pipes) if (pipe.x >= 0 && pipe.x < this.width) for (let y = 0; y < this.height; y++) if (y < pipe.gap || y > pipe.gap + 3) cells[y][pipe.x] = '#'; + const birdY = Math.round(this.y); + if (birdY >= 0 && birdY < this.height) cells[birdY][4] = '>'; + return `${frame(cells)}\nScore: ${this.score}${this.over ? ' GAME OVER' : ''}`; + } +} + +export class FlappyService { + constructor(db) { this.db = db; this.live = new WeakMap(); } + draw(ctx, text, help) { if (!ctx.channel?.destroyed) ctx.channel.write(`\x1b[?25l\x1b[2J\x1b[H${text}\r\n\r\n${help}`.replace(/\n/g, '\r\n')); } + display(ctx, game) { + const player = safeName(ctx.username); const scores = this.db.getLeaderboard('flappy', 3); + const board = scores.length ? scores.map((row, index) => `${index + 1}. ${row.player.slice(0, 16).padEnd(16)} ${row.score}`).join('\n') : 'No scores yet'; + return `${game.render()}\n\nYour high score: ${Math.max(game.score, this.db.getPlayerBest('flappy', player))}\nTop Flappy\n${board}`; + } + stop(ctx, message = '') { + const state = this.live.get(ctx); if (state?.timer) clearInterval(state.timer); + this.live.delete(ctx); ctx.tui?.setInputHandler(null); + if (message) ctx.tui?.finishLive(`\x1b[?25h${message}`); + } + start(ctx) { + if (!ctx.tui) return 'Flappy is interactive; connect with an SSH shell (without a remote command).'; + this.stop(ctx); const game = new FlappyGame(); const state = { game, timer: null, pendingFlap: false }; this.live.set(ctx, state); + const finish = () => { + if (!game.recorded) { this.db.addScore('flappy', safeName(ctx.username), game.score); game.recorded = true; } + if (state.timer) clearInterval(state.timer); state.timer = null; + this.draw(ctx, this.display(ctx, game), 'R: restart Q: quit'); + }; + const update = flap => { game.tick(flap); if (game.over) finish(); else this.draw(ctx, this.display(ctx, game), 'SPACE: flap Q: quit'); }; + ctx.tui.setInputHandler(input => { + if (input.includes('\x03') || /q/i.test(input) || input.includes('\x1b')) this.stop(ctx, 'Flappy exited.'); + else if (game.over && /r/i.test(input)) this.start(ctx); + else if (input.includes(' ')) state.pendingFlap = true; + return true; + }); + state.timer = setInterval(() => { update(state.pendingFlap); state.pendingFlap = false; }, 180); + this.draw(ctx, this.display(ctx, game), 'SPACE: flap Q: quit'); + return '__LIVE__'; + } + disconnect(ctx) { this.stop(ctx); } +} + +export function run({ ctx, api }) { return api.flappy.start(ctx); } diff --git a/src/commands/help.js b/src/commands/help.js index ec2856b..50603f7 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -1,6 +1,10 @@ export const name = 'help'; export const description = 'Show this help'; export function run({ visibleCommands, pageMap, isAdmin, ctx }) { const actions = visibleCommands.filter(command => !command.admin || isAdmin(ctx)).map(command => `${command.name.padEnd(18)} ${command.description}`); - const pages = [...pageMap.values()].filter(page => !visibleCommands.some(command => command.name === page.name)).map(page => `${page.name.padEnd(18)} Open ${page.title || page.name}`); + const seen = new Set(); + const pages = [...pageMap.values()].filter(page => { + if (seen.has(page.name) || page.visible === false || (page.requiredRole === 'admin' && !isAdmin(ctx)) || visibleCommands.some(command => command.name === page.name)) return false; + seen.add(page.name); return true; + }).map(page => `${page.name.padEnd(18)} ${page.description || `Open ${page.title || page.name}`}`); return [...actions, ...pages].join('\n'); } diff --git a/src/commands/leaderboard.js b/src/commands/leaderboard.js new file mode 100644 index 0000000..0ea587a --- /dev/null +++ b/src/commands/leaderboard.js @@ -0,0 +1,8 @@ +export const name = 'leaderboard'; +export const description = 'Show game high scores'; +export function run({ arg, api }) { + const game = arg || 'flappy'; + if (!['flappy', 'pong'].includes(game)) return 'Usage: leaderboard flappy|pong'; + const rows = api.db.getLeaderboard(game, 10); + return rows.length ? `${game.toUpperCase()} LEADERBOARD\n${rows.map((row, i) => `${String(i + 1).padStart(2)}. ${row.player.padEnd(16)} ${row.score}`).join('\n')}` : `No ${game} scores yet.`; +} diff --git a/src/commands/pages.js b/src/commands/pages.js index 61e45c0..cbba5c0 100644 --- a/src/commands/pages.js +++ b/src/commands/pages.js @@ -1,3 +1,9 @@ import { color } from '../terminal.js'; export const name = 'pages'; export const description = 'List available pages'; -export function run({ pageMap, config }) { return [...pageMap.values()].map(page => `${color(config.theme?.accent || 'yellow', page.name.padEnd(14))} ${page.title || page.name}`).join('\n') || 'No pages configured.'; } +export function run({ pageMap, config, isAdmin, ctx }) { + const seen = new Set(); + return [...pageMap.values()].filter(page => { + if (seen.has(page.name) || page.visible === false || (page.requiredRole === 'admin' && !isAdmin(ctx))) return false; + seen.add(page.name); return true; + }).map(page => `${color(config.theme?.accent || 'yellow', page.name.padEnd(14))} ${page.title || page.name}${page.description ? ` — ${page.description}` : ''}`).join('\n') || 'No pages configured.'; +} diff --git a/src/commands/pong.js b/src/commands/pong.js new file mode 100644 index 0000000..ebd61ba --- /dev/null +++ b/src/commands/pong.js @@ -0,0 +1,87 @@ +import { safeName } from '../content-policy.js'; + +export const name = 'pong'; +export const description = 'Play Pong against a player or bot'; + +function grid(width, height) { return Array.from({ length: height }, () => Array(width).fill(' ')); } +function frame(cells) { return `+${'-'.repeat(cells[0].length)}+\n${cells.map(row => `|${row.join('')}|`).join('\n')}\n+${'-'.repeat(cells[0].length)}+`; } + +export class PongGame { + constructor(bot = true, random = Math.random) { this.width = 30; this.height = 12; this.bot = bot; this.random = random; this.reset(); } + reset() { this.leftY = 4; this.rightY = 4; this.ballX = 15; this.ballY = 6; this.vx = -1; this.vy = 1; this.leftScore = 0; this.rightScore = 0; this.over = false; this.recorded = false; } + move(side, action) { + const delta = action === 'up' ? -1 : action === 'down' ? 1 : 0; + if (side === 'left') this.leftY = Math.max(0, Math.min(this.height - 3, this.leftY + delta)); + else this.rightY = Math.max(0, Math.min(this.height - 3, this.rightY + delta)); + } + tick(side, action) { + if (this.over) return; + this.move(side, action); + if (this.bot && this.random() > 0.25) this.rightY = Math.max(0, Math.min(this.height - 3, this.rightY + Math.sign(this.ballY - (this.rightY + 1)))); + let nextX = this.ballX + this.vx; let nextY = this.ballY + this.vy; + if (nextY < 0 || nextY >= this.height) { this.vy *= -1; nextY = this.ballY + this.vy; } + if (this.vx < 0 && nextX <= 1 && nextY >= this.leftY && nextY <= this.leftY + 2) { this.vx = 1; nextX = 2; } + if (this.vx > 0 && nextX >= this.width - 2 && nextY >= this.rightY && nextY <= this.rightY + 2) { this.vx = -1; nextX = this.width - 3; } + this.ballX = nextX; this.ballY = nextY; + if (this.ballX < 0) { this.rightScore++; this.serve(1); } + if (this.ballX >= this.width) { this.leftScore++; this.serve(-1); } + this.over = this.leftScore >= 5 || this.rightScore >= 5; + } + serve(direction) { this.ballX = 15; this.ballY = 6; this.vx = direction; this.vy = this.vy < 0 ? -1 : 1; } + render(side = 'left') { + const cells = grid(this.width, this.height); + for (let i = 0; i < 3; i++) { cells[this.leftY + i][1] = '|'; cells[this.rightY + i][this.width - 2] = '|'; } + if (this.ballY >= 0 && this.ballY < this.height && this.ballX >= 0 && this.ballX < this.width) cells[this.ballY][this.ballX] = 'o'; + const label = side === 'left' ? `${this.leftScore} - ${this.rightScore}` : `${this.rightScore} - ${this.leftScore}`; + return `${frame(cells)}\nYou - Opponent: ${label}${this.over ? ' MATCH OVER' : ''}`; + } +} + +export class PongService { + constructor(db) { this.db = db; this.live = new WeakMap(); this.waiting = null; } + draw(ctx, text, help) { if (!ctx.channel?.destroyed) ctx.channel.write(`\x1b[?25l\x1b[2J\x1b[H${text}\r\n\r\n${help}`.replace(/\n/g, '\r\n')); } + stop(ctx, message = '') { + const state = this.live.get(ctx); if (state?.timer) clearInterval(state.timer); + this.live.delete(ctx); ctx.tui?.setInputHandler(null); + if (message) ctx.tui?.finishLive(`\x1b[?25h${message}`); + } + start(ctx, mode) { + if (!['bot', 'online'].includes(mode)) return 'Usage: pong [bot|online]'; + if (!ctx.tui) return 'Pong is interactive; connect with an SSH shell (without a remote command).'; + if (mode === 'online') { + if (!this.waiting || this.waiting === ctx) { this.stop(ctx); this.waiting = ctx; return 'Waiting for another player. Type “pong online” from another session to join.'; } + const opponent = this.waiting; this.waiting = null; const game = new PongGame(false); + this.startMatch(opponent, game, 'left', ctx); this.startMatch(ctx, game, 'right', opponent); return '__LIVE__'; + } + if (this.waiting === ctx) this.waiting = null; + this.startMatch(ctx, new PongGame(true), 'left', null); return '__LIVE__'; + } + startMatch(ctx, game, side, opponent) { + this.stop(ctx); const state = { game, side, opponent, timer: null }; this.live.set(ctx, state); + const finish = () => { + if (!game.recorded) { this.db.addScore('pong', safeName(ctx.username), side === 'left' ? game.leftScore : game.rightScore); if (opponent) this.db.addScore('pong', safeName(opponent.username), side === 'left' ? game.rightScore : game.leftScore); game.recorded = true; } + const other = opponent && this.live.get(opponent); if (this.live.has(ctx)) this.stop(ctx, `${game.render(side)}\n\nPong match finished.`); if (other) this.stop(opponent, `${game.render(other.side)}\n\nPong match finished.`); + }; + const renderBoth = () => { this.draw(ctx, game.render(side), 'UP/DOWN arrows: move Q: quit'); if (opponent) this.draw(opponent, game.render(side === 'left' ? 'right' : 'left'), 'UP/DOWN arrows: move Q: quit'); }; + ctx.tui.setInputHandler(input => { + if (input.includes('\x03') || /q/i.test(input)) { this.leave(ctx); return true; } + if (input.includes('\x1b[A') || input.includes('\x1bOA')) game.move(side, 'up'); + if (input.includes('\x1b[B') || input.includes('\x1bOB')) game.move(side, 'down'); + renderBoth(); return true; + }); + if (side === 'left') state.timer = setInterval(() => { game.tick(side, 'stay'); if (game.over) finish(); else renderBoth(); }, 220); + renderBoth(); + } + leave(ctx) { + if (this.waiting === ctx) this.waiting = null; + const state = this.live.get(ctx); const opponent = state?.opponent; this.stop(ctx, 'You left the Pong match.'); + if (opponent && this.live.has(opponent)) this.stop(opponent, 'Your Pong opponent left the match.'); + } + disconnect(ctx) { + if (this.waiting === ctx) this.waiting = null; + const opponent = this.live.get(ctx)?.opponent; this.stop(ctx); + if (opponent && this.live.has(opponent)) this.stop(opponent, 'Your Pong opponent disconnected.'); + } +} + +export function run({ arg, ctx, api }) { return api.pong.start(ctx, arg.toLowerCase() || 'bot'); } diff --git a/src/config.js b/src/config.js index 4b261cb..225f28b 100644 --- a/src/config.js +++ b/src/config.js @@ -4,16 +4,57 @@ import path from 'node:path'; const root = process.cwd(); const file = process.env.PORTFOLIO_CONFIG || path.join(root, 'data', 'config.json'); const example = path.join(root, 'config.example.json'); +const colors = new Set(['cyan', 'yellow', 'green', 'magenta', 'blue', 'white', 'red', 'brightBlack']); +function port(value, name) { + if (!Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`Invalid ${name}: expected an integer from 1 to 65535.`); + return value; +} +export function validateConfig(config) { + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Configuration must be a JSON object.'); + if (config.theme !== undefined) { + if (!config.theme || typeof config.theme !== 'object') throw new Error('Invalid theme: expected an object.'); + for (const key of ['primary', 'accent', 'muted']) if (config.theme[key] !== undefined && !colors.has(config.theme[key])) throw new Error(`Invalid theme.${key}: use one of ${[...colors].join(', ')}.`); + } + config.host ??= {}; + config.host.port = port(config.host.port, 'host.port'); + config.host.notificationPort = port(config.host.notificationPort, 'host.notificationPort'); + if (typeof config.host.hostKeyPath !== 'string' || !config.host.hostKeyPath.trim()) throw new Error('Invalid host.hostKeyPath: expected a non-empty path.'); + if (config.databasePath !== undefined && (typeof config.databasePath !== 'string' || !config.databasePath.trim())) throw new Error('Invalid databasePath: expected a non-empty path.'); + if (config.dashboard !== undefined) { + if (!config.dashboard || typeof config.dashboard !== 'object') throw new Error('Invalid dashboard: expected an object.'); + if (config.dashboard.port !== undefined) config.dashboard.port = port(config.dashboard.port, 'dashboard.port'); + if (config.dashboard.host !== undefined && (typeof config.dashboard.host !== 'string' || !config.dashboard.host.trim())) throw new Error('Invalid dashboard.host: expected a non-empty host.'); + if (config.dashboard.tokenFile !== undefined && (typeof config.dashboard.tokenFile !== 'string' || !config.dashboard.tokenFile.trim())) throw new Error('Invalid dashboard.tokenFile: expected a non-empty path.'); + } + if (config.board !== undefined) { + if (!config.board || typeof config.board !== 'object') throw new Error('Invalid board: expected an object.'); + for (const key of ['maxCharacters', 'cooldownSeconds', 'dailyLimit']) if (config.board[key] !== undefined && (!Number.isInteger(config.board[key]) || config.board[key] < 1)) throw new Error(`Invalid board.${key}: expected a positive integer.`); + if (config.board.maxCharacters > 240) throw new Error('Invalid board.maxCharacters: maximum is 240.'); + } + if (config.limits?.maxInputLength !== undefined && (!Number.isInteger(config.limits.maxInputLength) || config.limits.maxInputLength < 64 || config.limits.maxInputLength > 4096)) throw new Error('Invalid limits.maxInputLength: expected an integer from 64 to 4096.'); + if (!Array.isArray(config.adminPublicKeys)) throw new Error('Invalid adminPublicKeys: expected an array of SSH public-key entries.'); + for (const [index, entry] of config.adminPublicKeys.entries()) { + const fields = typeof entry === 'string' ? entry.trim().split(/\s+/) : []; + const blob = fields.length === 1 ? fields[0] : fields[1]; + if (!blob || !/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) throw new Error(`Invalid adminPublicKeys[${index}]: expected a base64 key blob or OpenSSH public-key line.`); + } + return config; +} export function loadConfig() { if (!fs.existsSync(file)) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.copyFileSync(example, file); console.log(`Created ${file}; edit it to personalize your portfolio.`); } - const config = JSON.parse(fs.readFileSync(file, 'utf8')); + let config; + try { config = JSON.parse(fs.readFileSync(file, 'utf8')); } + catch (error) { throw new Error(`Unable to parse ${file}: ${error.message}`); } config.host ??= {}; config.host.port = Number(process.env.PORT || config.host.port || 2222); config.host.notificationPort = Number(process.env.NOTIFICATION_PORT || config.host.notificationPort || 7777); - return config; + config.dashboard ??= {}; + config.dashboard.port = Number(process.env.DASHBOARD_PORT || config.dashboard.port || 3000); + try { return validateConfig(config); } + catch (error) { throw new Error(`Invalid configuration in ${file}: ${error.message}`); } } export const configPath = file; diff --git a/src/content-policy.js b/src/content-policy.js new file mode 100644 index 0000000..79ce32d --- /dev/null +++ b/src/content-policy.js @@ -0,0 +1,14 @@ +export function safeName(value, fallback = 'visitor') { + const clean = String(value || '').normalize('NFKC').replace(/[^\p{L}\p{N}_.-]+/gu, '').slice(0, 32); + return clean || fallback; +} + +export function validatePost(value, maxCharacters = 180) { + const text = String(value || '').normalize('NFC').replace(/\s+/gu, ' ').trim(); + if (!text) return { ok: false, reason: 'Write a short message after “board post”.' }; + const characters = Array.from(text); + if (characters.length > maxCharacters || Buffer.byteLength(text, 'utf8') > 512) return { ok: false, reason: `Messages are limited to ${maxCharacters} characters.` }; + if (/\p{Extended_Pictographic}/u.test(text)) return { ok: false, reason: 'Emoji and pictographs are not accepted on the bulletin board.' }; + if (/\p{M}{3,}/u.test(text) || /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}]/u.test(text)) return { ok: false, reason: 'The message contains unsupported characters.' }; + return { ok: true, value: text }; +} diff --git a/src/dashboard.js b/src/dashboard.js new file mode 100644 index 0000000..13ae97f --- /dev/null +++ b/src/dashboard.js @@ -0,0 +1,66 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import http from 'node:http'; + +const html = ` + +SSH monitor

SSH server monitor

connecting…

ACTIVE CONNECTIONS

RECENT CONNECTIONS & COMMANDS

BANNED IPS

`; + +function json(response, status, body) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + response.end(JSON.stringify(body)); +} + +function readJson(request) { + return new Promise((resolve, reject) => { + let body = ''; + request.on('data', chunk => { body += chunk; if (body.length > 8192) request.destroy(); }); + request.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { reject(new Error('Invalid JSON.')); } }); + request.on('error', reject); + }); +} + +export function loadDashboardToken(file = 'data/dashboard-token') { + if (process.env.DASHBOARD_TOKEN) return process.env.DASHBOARD_TOKEN; + const resolved = path.resolve(file); + if (!fs.existsSync(resolved)) { + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, crypto.randomBytes(24).toString('base64url'), { mode: 0o600 }); + } + return fs.readFileSync(resolved, 'utf8').trim(); +} + +export function createDashboardServer({ token, getState, disconnect, ban, unban }) { + const cookie = request => request.headers.cookie?.split(';').map(v => v.trim()).find(v => v.startsWith('ssh_monitor='))?.slice(12); + const authorized = request => cookie(request) === token || request.headers.authorization === `Bearer ${token}`; + return http.createServer(async (request, response) => { + const url = new URL(request.url, 'http://localhost'); + if (url.pathname === '/' && url.searchParams.get('token') === token) { + response.writeHead(302, { location: '/', 'set-cookie': `ssh_monitor=${token}; HttpOnly; SameSite=Strict; Path=/` }); response.end(); return; + } + if (!authorized(request)) { response.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); response.end('Unauthorized. Open the tokenized dashboard URL printed by the server.'); return; } + if (request.method === 'GET' && url.pathname === '/') { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'none'" }); response.end(html); return; } + if (request.method === 'GET' && url.pathname === '/api/state') { json(response, 200, getState()); return; } + if (request.method === 'POST' && ['/api/disconnect', '/api/ban', '/api/unban'].includes(url.pathname)) { + if (!String(request.headers['content-type']).startsWith('application/json')) { json(response, 415, { error: 'JSON required.' }); return; } + try { + const body = await readJson(request); + const result = url.pathname === '/api/disconnect' ? disconnect(body.id) : url.pathname === '/api/ban' ? ban(body.ip) : unban(body.ip); + json(response, result ? 200 : 404, result ? { ok: true } : { error: 'Target not found.' }); + } catch (error) { json(response, 400, { error: error.message }); } + return; + } + json(response, 404, { error: 'Not found.' }); + }); +} diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000..92f3834 --- /dev/null +++ b/src/database.js @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import Database from 'better-sqlite3'; + +export class PortfolioDatabase { + constructor(file = 'data/portfolio.sqlite') { + const resolved = path.resolve(file); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + this.db = new Database(resolved); + try { fs.chmodSync(resolved, 0o600); } catch {} + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('busy_timeout = 3000'); + this.db.exec(` + CREATE TABLE IF NOT EXISTS board_posts ( + id INTEGER PRIMARY KEY, + author TEXT NOT NULL CHECK(length(author) BETWEEN 1 AND 32), + body TEXT NOT NULL CHECK(length(body) BETWEEN 1 AND 240), + ip_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS board_posts_recent_ip ON board_posts(ip_hash, created_at); + CREATE TABLE IF NOT EXISTS leaderboard_scores ( + id INTEGER PRIMARY KEY, + game TEXT NOT NULL CHECK(game IN ('flappy', 'pong')), + player TEXT NOT NULL CHECK(length(player) BETWEEN 1 AND 32), + score INTEGER NOT NULL CHECK(score BETWEEN 0 AND 100000000), + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS leaderboard_game_score ON leaderboard_scores(game, score DESC, created_at ASC); + CREATE TABLE IF NOT EXISTS ssh_sessions ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + ip TEXT NOT NULL, + client TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + connected_at INTEGER NOT NULL, + disconnected_at INTEGER, + disconnect_reason TEXT + ); + CREATE INDEX IF NOT EXISTS ssh_sessions_recent ON ssh_sessions(connected_at DESC); + CREATE TABLE IF NOT EXISTS ssh_commands ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES ssh_sessions(id) ON DELETE CASCADE, + command TEXT NOT NULL, + executed_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS ssh_commands_session_recent ON ssh_commands(session_id, executed_at DESC); + CREATE TABLE IF NOT EXISTS ip_bans ( + ip TEXT PRIMARY KEY, + banned_at INTEGER NOT NULL + ); + `); + this.insertPost = this.db.prepare('INSERT INTO board_posts(author, body, ip_hash, created_at) VALUES (?, ?, ?, ?)'); + this.lastPost = this.db.prepare('SELECT created_at FROM board_posts WHERE ip_hash = ? ORDER BY created_at DESC LIMIT 1'); + this.dailyPosts = this.db.prepare('SELECT count(*) AS count FROM board_posts WHERE ip_hash = ? AND created_at >= ?'); + this.recentPosts = this.db.prepare('SELECT id, author, body, created_at FROM board_posts ORDER BY id DESC LIMIT ?'); + this.deletePost = this.db.prepare('DELETE FROM board_posts WHERE id = ?'); + this.insertScore = this.db.prepare('INSERT INTO leaderboard_scores(game, player, score, created_at) VALUES (?, ?, ?, ?)'); + this.topScores = this.db.prepare(`SELECT player, max(score) AS score, min(created_at) AS created_at + FROM leaderboard_scores WHERE game = ? GROUP BY player ORDER BY score DESC, created_at ASC LIMIT ?`); + this.playerBest = this.db.prepare('SELECT coalesce(max(score), 0) AS score FROM leaderboard_scores WHERE game = ? AND player = ?'); + this.insertSession = this.db.prepare('INSERT INTO ssh_sessions(id, username, ip, client, is_admin, connected_at) VALUES (?, ?, ?, ?, ?, ?)'); + this.updateSessionIdentity = this.db.prepare('UPDATE ssh_sessions SET username = ?, is_admin = ? WHERE id = ?'); + this.endSession = this.db.prepare('UPDATE ssh_sessions SET disconnected_at = ?, disconnect_reason = ? WHERE id = ? AND disconnected_at IS NULL'); + this.endOpenSessions = this.db.prepare('UPDATE ssh_sessions SET disconnected_at = ?, disconnect_reason = ? WHERE disconnected_at IS NULL'); + this.insertCommand = this.db.prepare('INSERT INTO ssh_commands(session_id, command, executed_at) VALUES (?, ?, ?)'); + this.recentSessions = this.db.prepare(`SELECT id, username, ip, client, is_admin, connected_at, disconnected_at, disconnect_reason + FROM ssh_sessions ORDER BY connected_at DESC LIMIT ?`); + this.sessionCommands = this.db.prepare(`SELECT id, session_id, command, executed_at FROM ssh_commands + WHERE session_id = ? ORDER BY executed_at DESC LIMIT ?`); + this.insertBan = this.db.prepare('INSERT INTO ip_bans(ip, banned_at) VALUES (?, ?) ON CONFLICT(ip) DO UPDATE SET banned_at = excluded.banned_at'); + this.deleteBan = this.db.prepare('DELETE FROM ip_bans WHERE ip = ?'); + this.findBan = this.db.prepare('SELECT 1 FROM ip_bans WHERE ip = ?'); + this.listBansStatement = this.db.prepare('SELECT ip, banned_at FROM ip_bans ORDER BY banned_at DESC'); + } + + static hashIp(ip) { return crypto.createHash('sha256').update(String(ip || 'unknown')).digest('hex'); } + + addBoardPost({ author, body, ip, cooldownSeconds = 60, dailyLimit = 5, now = Date.now() }) { + const ipHash = PortfolioDatabase.hashIp(ip); + const transaction = this.db.transaction(() => { + const last = this.lastPost.get(ipHash)?.created_at; + if (last && now - last < cooldownSeconds * 1000) return { ok: false, reason: `Please wait ${Math.ceil((cooldownSeconds * 1000 - (now - last)) / 1000)} seconds before posting again.` }; + if (this.dailyPosts.get(ipHash, now - 86400000).count >= dailyLimit) return { ok: false, reason: 'Daily posting limit reached for this address.' }; + const result = this.insertPost.run(author, body, ipHash, now); + return { ok: true, id: Number(result.lastInsertRowid) }; + }); + return transaction.immediate(); + } + + listBoardPosts(limit = 12) { return this.recentPosts.all(Math.max(1, Math.min(50, limit))); } + deleteBoardPost(id) { return this.deletePost.run(id).changes === 1; } + addScore(game, player, score, now = Date.now()) { this.insertScore.run(game, player, Math.max(0, Math.floor(score)), now); } + getLeaderboard(game, limit = 10) { return this.topScores.all(game, Math.max(1, Math.min(25, limit))); } + getPlayerBest(game, player) { return this.playerBest.get(game, player).score; } + startSshSession({ id, username = 'visitor', ip = 'unknown', client = 'unknown', admin = false, now = Date.now() }) { + this.insertSession.run(id, String(username).slice(0, 128), String(ip).slice(0, 128), String(client).slice(0, 256), admin ? 1 : 0, now); + } + updateSshSessionIdentity(id, username, admin = false) { this.updateSessionIdentity.run(String(username).slice(0, 128), admin ? 1 : 0, id); } + finishSshSession(id, reason = 'closed', now = Date.now()) { return this.endSession.run(now, String(reason).slice(0, 128), id).changes === 1; } + finishOpenSshSessions(reason = 'server restarted', now = Date.now()) { return this.endOpenSessions.run(now, String(reason).slice(0, 128)).changes; } + logSshCommand(sessionId, command, now = Date.now()) { this.insertCommand.run(sessionId, String(command).slice(0, 4096), now); } + listSshSessions(limit = 100) { return this.recentSessions.all(Math.max(1, Math.min(500, limit))); } + listSshCommands(sessionId, limit = 100) { return this.sessionCommands.all(sessionId, Math.max(1, Math.min(500, limit))); } + banIp(ip, now = Date.now()) { this.insertBan.run(String(ip), now); } + unbanIp(ip) { return this.deleteBan.run(String(ip)).changes === 1; } + isIpBanned(ip) { return Boolean(this.findBan.get(String(ip))); } + listIpBans() { return this.listBansStatement.all(); } + close() { this.db.close(); } +} diff --git a/src/pages/_template.js b/src/pages/_template.js new file mode 100644 index 0000000..7a933b9 --- /dev/null +++ b/src/pages/_template.js @@ -0,0 +1,13 @@ +// Copy this file, remove the leading underscore, and register the module in index.js. +export const name = 'example'; // Command name: open example (or just example) +export const title = 'Example Page'; // Box heading +export const order = 100; // Lower values appear first in navigation +export const aliases = ['sample']; // Optional alternative command names +export const visible = true; // false hides navigation, but keeps the page addressable +// export const requiredRole = 'admin'; // Optional: only admins can discover or open the page +export const description = 'What this page contains'; + +export function render({ config, ctx, api }) { + const visitor = api.isAdmin(ctx) ? 'owner' : 'visitor'; + return `Hello, ${visitor}. Welcome to ${config.persona?.name || 'this portfolio'}.`; +} diff --git a/src/pages/about.js b/src/pages/about.js index 45bc76e..d4019df 100644 --- a/src/pages/about.js +++ b/src/pages/about.js @@ -2,6 +2,9 @@ import { wrap } from '../terminal.js'; export const name = 'about'; export const title = 'About'; +export const order = 10; +export const aliases = ['me']; +export const description = 'A short introduction'; // Edit this function to customize your About page. export function render({ config }) { diff --git a/src/pages/home.js b/src/pages/home.js index 75c0db8..1e4f671 100644 --- a/src/pages/home.js +++ b/src/pages/home.js @@ -2,6 +2,8 @@ import { color, wrap } from '../terminal.js'; export const name = 'home'; export const title = 'Home'; +export const order = 0; +export const description = 'Portfolio landing page'; // Edit this function to customize the landing page. export function render({ config, ownerOnline }) { diff --git a/src/pages/index.js b/src/pages/index.js index 4f1ce8a..9c81076 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -5,4 +5,22 @@ import * as links from './links.js'; // Add new page modules to this registry to make them available as commands. export const pages = [home, about, projects, links]; -export const pageMap = new Map(pages.map(page => [page.name, page])); + +const invalid = pages.filter(page => !/^[a-z][a-z0-9-]*$/.test(page.name) || typeof page.render !== 'function' || + (page.order !== undefined && !Number.isFinite(page.order)) || + (page.aliases !== undefined && !Array.isArray(page.aliases)) || + (page.visible !== undefined && typeof page.visible !== 'boolean') || + (page.requiredRole !== undefined && page.requiredRole !== 'admin')); +if (invalid.length) throw new Error(`Invalid page module(s): ${invalid.map(page => page.name || '').join(', ')}. Pages need a kebab-case name and render function.`); +const names = new Set(); +for (const page of pages) { + const aliases = Array.isArray(page.aliases) ? page.aliases : []; + for (const name of [page.name, ...aliases]) { + if (!/^[a-z][a-z0-9-]*$/.test(name) || names.has(name)) throw new Error(`Invalid or duplicate page name/alias: ${name}`); + names.add(name); + } +} + +export const pageMap = new Map(pages + .slice().sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.name.localeCompare(b.name)) + .flatMap(page => [page.name, ...(page.aliases || [])].map(name => [name, page]))); diff --git a/src/pages/links.js b/src/pages/links.js index 96b00ca..18c71f5 100644 --- a/src/pages/links.js +++ b/src/pages/links.js @@ -1,7 +1,9 @@ export const name = 'links'; export const title = 'Links'; +export const order = 30; +export const description = 'External links and profiles'; -// Edit these links directly, or move them into your config file. +// Edit this module directly. config.pages.links is retained as a legacy content fallback. export function render({ config }) { return (config.pages?.links?.lines || [ 'GitHub: https://github.com/yourname', diff --git a/src/pages/projects.js b/src/pages/projects.js index b894fc6..69a0cdd 100644 --- a/src/pages/projects.js +++ b/src/pages/projects.js @@ -1,7 +1,9 @@ export const name = 'projects'; export const title = 'Projects'; +export const order = 20; +export const description = 'Selected work and projects'; -// Replace these entries with your own work, or load them from config. +// Replace these entries with your own work. config.pages.projects is a legacy content fallback. export function render({ config }) { return (config.pages?.projects?.lines || [ 'Project One — what it does', diff --git a/src/server.js b/src/server.js index bc3cf69..a0203d4 100644 --- a/src/server.js +++ b/src/server.js @@ -8,11 +8,20 @@ import { loadConfig, configPath } from './config.js'; import { color, clear, header, box } from './terminal.js'; import { createCommandRouter } from './commands.js'; import { configureTerminal, createTuiSession } from './tui.js'; +import { PortfolioDatabase } from './database.js'; +import { FlappyService } from './commands/flappy.js'; +import { PongService } from './commands/pong.js'; +import { createDashboardServer, loadDashboardToken } from './dashboard.js'; const { Server } = ssh2; const config = loadConfig(); +const database = new PortfolioDatabase(config.databasePath || 'data/portfolio.sqlite'); +database.finishOpenSshSessions(); +const flappy = new FlappyService(database); +const pong = new PongService(database); const sessions = new Set(); +const connections = new Map(); const notifications = new Set(); const hostKey = path.resolve(config.host.hostKeyPath || 'data/host.key'); if (!fs.existsSync(hostKey)) { fs.mkdirSync(path.dirname(hostKey), { recursive: true }); fs.writeFileSync(hostKey, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs1', format: 'pem' })); } @@ -28,19 +37,43 @@ function renderHome(ctx) { } function renderPage(ctx, input, content = '') { const width = Math.max(40, ctx.cols || 80); const [command, argument] = input.split(/\s+/); const pageName = command === 'open' ? argument : command; - const title = config.pages?.[pageName]?.title || (command ? command[0].toUpperCase() + command.slice(1) : 'Portfolio'); + const title = router.page(pageName)?.title || (command ? command[0].toUpperCase() + command.slice(1) : 'Portfolio'); return `${clear}${header(`● ${config.persona?.name || 'Your Name'} — ${config.persona?.tagline || ''}`, `${count()} connected`, width, config.theme?.primary, config.theme?.muted)}\r\n${color(config.theme?.muted || 'brightBlack', config.persona?.location || '')}\r\n\r\n${box(title, String(content || '').split(/\r?\n/), width, config.theme)}\r\n\r\n`; } function refreshHeaders() { for (const s of sessions) s.tui?.updateHeader(header(`● ${config.persona?.name || 'Your Name'} — ${config.persona?.tagline || ''}`, `${count()} connected`, s.ctx.cols, config.theme?.primary, config.theme?.muted)); } -const router = createCommandRouter({ config, isAdmin, ownerOnline: () => [...sessions].some(s => s.ctx.admin), sendNotification, reload: () => Object.assign(config, loadConfig()), broadcast: line => { for (const s of sessions) s.tui?.notify(color(config.theme?.accent || 'yellow', line)); } }); +const router = createCommandRouter({ config, db: database, flappy, pong, isAdmin, ownerOnline: () => [...sessions].some(s => s.ctx.admin), sendNotification, reload: () => Object.assign(config, loadConfig()), broadcast: line => { for (const s of sessions) s.tui?.notify(color(config.theme?.accent || 'yellow', line)); } }); function handleSession(channel, ctx) { ctx.channel = channel; const active = { channel, ctx, tui: null }; sessions.add(active); refreshHeaders(); configureTerminal(channel, config.persona?.name || 'SSH Portfolio'); - active.tui = createTuiSession({ channel, ctx, router, prompt, renderHome, renderPage, onClose: () => { sessions.delete(active); refreshHeaders(); } }); + active.tui = createTuiSession({ channel, ctx, router, prompt, renderHome, renderPage, maxInputLength: config.limits?.maxInputLength || 1024, onCommand: command => { if (ctx.connection?.sessionId) database.logSshCommand(ctx.connection.sessionId, command); }, onClose: () => { flappy.disconnect(ctx); pong.disconnect(ctx); sessions.delete(active); refreshHeaders(); } }); + ctx.tui = active.tui; active.tui.showHome(); } -const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-portfolio' }, client => { - const ctx = { admin: false, channel: null, cols: 80, username: 'visitor' }; +function dashboardState() { + const rows = database.listSshSessions(200).map(row => ({ id: row.id, username: row.username, ip: row.ip, client: row.client, admin: Boolean(row.is_admin), connectedAt: row.connected_at, disconnectedAt: row.disconnected_at, commands: database.listSshCommands(row.id, 40).reverse().map(command => command.command) })); + const activeIds = new Set([...connections.values()].filter(connection => connection.sessionId).map(connection => connection.sessionId)); + return { active: rows.filter(row => activeIds.has(row.id)), history: rows.filter(row => !activeIds.has(row.id)), bans: database.listIpBans().map(row => ({ ip: row.ip, bannedAt: row.banned_at })) }; +} +function closeConnection(id, reason = 'closed by administrator') { + const connection = connections.get(id); if (!connection) return false; + connection.client.end(); database.finishSshSession(id, reason); return true; +} +function banIp(ip) { + if (!ip || typeof ip !== 'string') return false; + database.banIp(ip); for (const connection of connections.values()) if (connection.ctx.ip === ip) closeConnection(connection.id, 'IP banned'); return true; +} +function unbanIp(ip) { return typeof ip === 'string' && database.unbanIp(ip); } +const dashboardToken = loadDashboardToken(config.dashboard?.tokenFile || 'data/dashboard-token'); +const dashboard = createDashboardServer({ token: dashboardToken, getState: dashboardState, disconnect: closeConnection, ban: banIp, unban: unbanIp }); +dashboard.listen(config.dashboard?.port || 3000, config.dashboard?.host || '127.0.0.1', () => console.log(`SSH dashboard: http://${config.dashboard?.host || '127.0.0.1'}:${config.dashboard?.port || 3000}/?token=${dashboardToken}`)); + +const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-portfolio' }, (client, info = {}) => { + const ctx = { admin: false, channel: null, cols: 80, username: 'visitor', ip: client._sock?.remoteAddress || 'unknown' }; + const connection = { id: crypto.randomUUID(), client, ctx, sessionId: null }; + ctx.connection = connection; connections.set(connection.id, connection); + if (database.isIpBanned(ctx.ip)) { client.end(); connections.delete(connection.id); return; } + connection.clientInfo = info.header?.versions?.software || 'unknown'; + client.once('close', () => { if (connection.sessionId) database.finishSshSession(connection.sessionId, 'connection closed'); connections.delete(connection.id); }); client.on('authentication', ctxAuth => { ctx.username = ctxAuth.username || 'visitor'; if (ctxAuth.method === 'none') { ctxAuth.accept(); return; } @@ -50,7 +83,10 @@ const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-po if (allowed && (!ctxAuth.signature || ctxAuth.key.verify(ctxAuth.blob, ctxAuth.signature, ctxAuth.hashAlgo))) { ctx.admin = true; ctxAuth.accept(); return; } } ctxAuth.reject(['none', 'publickey']); - }).on('ready', () => client.on('session', accept => { const sshSession = accept(); sshSession.on('pty', (acceptPty, rejectPty, info) => { ctx.cols = info.cols || 80; acceptPty(); sshSession.on('window-change', (acceptResize, rejectResize, resize) => { ctx.cols = resize.cols || ctx.cols; refreshHeaders(); }); }); sshSession.on('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((router.execute(ctx, info.command) || '') + '\r\n'); e.exit(0); e.end(); }); })).on('error', () => {}); + }).on('ready', () => { connection.sessionId = connection.id; database.startSshSession({ id: connection.sessionId, username: ctx.username, ip: ctx.ip, client: connection.clientInfo, admin: ctx.admin }); client.on('session', accept => { const sshSession = accept(); sshSession.on('pty', (acceptPty, rejectPty, info) => { ctx.cols = info.cols || 80; acceptPty(); sshSession.on('window-change', (acceptResize, rejectResize, resize) => { ctx.cols = resize.cols || ctx.cols; refreshHeaders(); }); }); sshSession.on('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); database.logSshCommand(connection.sessionId, info.command); e.write((router.execute(ctx, info.command) || '') + '\r\n'); e.exit(0); e.end(); }); }); }).on('error', () => {}); }); server.listen(config.host.port, '0.0.0.0', () => console.log(`SSH portfolio listening on port ${config.host.port}; config: ${configPath}`)); -net.createServer(socket => { notifications.add(socket); socket.on('data', () => {}); socket.on('close', () => notifications.delete(socket)); }).listen(config.host.notificationPort, '127.0.0.1'); +const notificationServer = net.createServer(socket => { notifications.add(socket); socket.on('data', () => {}); socket.on('close', () => notifications.delete(socket)); }); +notificationServer.listen(config.host.notificationPort, '127.0.0.1'); +function shutdown() { for (const session of sessions) session.channel.end(); for (const socket of notifications) socket.destroy(); dashboard.close(); server.close(); notificationServer.close(() => database.close()); } +process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); diff --git a/src/terminal.js b/src/terminal.js index 0d68c36..fc10bf9 100644 --- a/src/terminal.js +++ b/src/terminal.js @@ -17,7 +17,7 @@ export function box(title, lines, width, theme = {}) { const titleText = ` ${truncate(title, inner - 2)} `; const topFill = Math.max(0, outer - titleText.length - 2); let output = `${color(muted, '╭─')}${color(primary, titleText)}${color(muted, `${'─'.repeat(topFill)}╮`)}\r\n`; for (const source of lines) { - const wrapped = wrap(source, inner); + const wrapped = visibleLength(source) <= inner ? [source] : wrap(source, inner); for (const line of wrapped) output += `${color(muted, '│')} ${line}${' '.repeat(Math.max(0, inner - visibleLength(line)))} ${color(muted, '│')}\r\n`; } return output + color(muted, `╰${'─'.repeat(outer - 2)}╯`); diff --git a/src/tui.js b/src/tui.js index 5ee32c6..59c3ebc 100644 --- a/src/tui.js +++ b/src/tui.js @@ -5,27 +5,30 @@ export function configureTerminal(stream, title) { stream.write(`\x1b]2;${String(title).replace(/[\x00-\x1f\x7f]/g, '')}\x07`); } -export function createTuiSession({ channel, ctx, router, renderHome, renderPage, prompt, onClose }) { - const state = { input: '', cursor: 0, history: [], historyIndex: 0, closed: false }; +export function createTuiSession({ channel, ctx, router, renderHome, renderPage, prompt, onClose, onCommand, maxInputLength = 1024 }) { + const state = { input: '', cursor: 0, history: [], historyIndex: 0, closed: false, inputHandler: null }; const redrawInput = () => { if (state.closed) return; channel.write(`\r\x1b[2K${prompt(ctx)}${state.input}`); if (state.cursor < state.input.length) channel.write(`\x1b[${state.input.length - state.cursor}D`); }; const insert = text => { - const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/[\r\n]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, ''); - if (!clean) return; state.input = state.input.slice(0, state.cursor) + clean + state.input.slice(state.cursor); state.cursor += clean.length; redrawInput(); + let clean = text.replace(/\x1b\[20[01]~/g, '').replace(/[\r\n]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, ''); + if (!clean) return; const room = Math.max(0, maxInputLength - state.input.length); clean = clean.slice(0, room); if (!clean) return; state.input = state.input.slice(0, state.cursor) + clean + state.input.slice(state.cursor); state.cursor += clean.length; redrawInput(); }; const showHome = () => { if (!state.closed) channel.write(renderHome(ctx) + prompt(ctx) + state.input); }; - const notify = text => { if (!state.closed) { channel.write(`\r\x1b[2K${crlf(text)}\r\n`); redrawInput(); } }; - const updateHeader = text => { if (!state.closed) channel.write(`\x1b7\x1b[1;1H\x1b[2K${text}\x1b8`); }; + const notify = text => { if (!state.closed && !state.inputHandler) { channel.write(`\r\x1b[2K${crlf(text)}\r\n`); redrawInput(); } }; + const updateHeader = text => { if (!state.closed && !state.inputHandler) channel.write(`\x1b7\x1b[1;1H\x1b[2K${text}\x1b8`); }; + const setInputHandler = handler => { state.inputHandler = handler; }; + const finishLive = text => { state.inputHandler = null; if (!state.closed) { channel.write(`\x1b[2J\x1b[H${crlf(text)}\r\n`); redrawInput(); } }; const submit = () => { const input = state.input.trim(); channel.write('\r\n'); - if (input) { if (state.history.at(-1) !== input) state.history.push(input); if (state.history.length > 100) state.history.shift(); } + if (input) { if (state.history.at(-1) !== input) state.history.push(input); if (state.history.length > 100) state.history.shift(); onCommand?.(input); } state.historyIndex = state.history.length; state.input = ''; state.cursor = 0; const name = input.split(/\s+/)[0]?.toLowerCase() || 'home'; if (name === 'home' || name === 'clear') { router.execute(ctx, input); showHome(); return; } const output = router.execute(ctx, input); if (state.closed || channel.destroyed) return; + if (output === '__LIVE__') return; channel.write(renderPage(ctx, input, output) + prompt(ctx)); }; const history = direction => { @@ -35,6 +38,7 @@ export function createTuiSession({ channel, ctx, router, renderHome, renderPage, }; const move = delta => { const next = Math.max(0, Math.min(state.input.length, state.cursor + delta)); if (next !== state.cursor) { channel.write(delta < 0 ? `\x1b[${state.cursor - next}D` : `\x1b[${next - state.cursor}C`); state.cursor = next; } }; channel.on('data', data => { + if (state.inputHandler?.(data.toString()) === true) return; let key = data.toString(); if (key.includes('\x1b[200~')) { insert(key); return; } while (key.length) { @@ -49,5 +53,5 @@ export function createTuiSession({ channel, ctx, router, renderHome, renderPage, } }); channel.on('close', () => { state.closed = true; onClose?.(); }); - return { showHome, notify, redrawInput, updateHeader, state }; + return { showHome, notify, redrawInput, updateHeader, setInputHandler, finishLive, state }; } diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..41b85a1 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateConfig } from '../src/config.js'; + +const valid = () => ({ + theme: { primary: 'cyan', accent: 'yellow', muted: 'brightBlack' }, + host: { port: 2222, notificationPort: 7777, hostKeyPath: 'data/host.key' }, + adminPublicKeys: [] +}); + +test('accepts a valid configuration', () => assert.equal(validateConfig(valid()).host.port, 2222)); +test('rejects invalid ports with an actionable path', () => { + const config = valid(); config.host.port = 70000; + assert.throws(() => validateConfig(config), /host\.port/); +}); +test('rejects unknown colors with an actionable path', () => { + const config = valid(); config.theme.primary = 'orange'; + assert.throws(() => validateConfig(config), /theme\.primary/); +}); +test('accepts key blobs and OpenSSH public-key lines', () => { + const config = valid(); config.adminPublicKeys = ['QUJDRA==', 'ssh-ed25519 QUJDRA== owner']; + assert.doesNotThrow(() => validateConfig(config)); +}); diff --git a/test/content-policy.test.js b/test/content-policy.test.js new file mode 100644 index 0000000..cbc8f40 --- /dev/null +++ b/test/content-policy.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { safeName, validatePost } from '../src/content-policy.js'; + +test('bulletin posts normalize whitespace and reject terminal controls', () => { + assert.deepEqual(validatePost(' hello world '), { ok: true, value: 'hello world' }); + assert.match(validatePost('hello\u001b[2J').reason, /unsupported/); +}); + +test('bulletin posts reject emoji and excessive length', () => { + assert.match(validatePost('hello 😀').reason, /Emoji/); + assert.match(validatePost('x'.repeat(181)).reason, /limited/); +}); + +test('names are reduced to safe display characters', () => assert.equal(safeName('\u001b[31m bob!!'), '31mbob')); diff --git a/test/database.test.js b/test/database.test.js new file mode 100644 index 0000000..8835fd5 --- /dev/null +++ b/test/database.test.js @@ -0,0 +1,19 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PortfolioDatabase } from '../src/database.js'; + +test('database rate-limits posts and stores leaderboard scores', t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-portfolio-')); + const db = new PortfolioDatabase(path.join(directory, 'test.sqlite')); + t.after(() => { db.close(); fs.rmSync(directory, { recursive: true, force: true }); }); + assert.equal(db.addBoardPost({ author: 'visitor', body: 'hello', ip: '127.0.0.1', now: 100000 }).ok, true); + assert.match(db.addBoardPost({ author: 'visitor', body: 'again', ip: '127.0.0.1', now: 100001 }).reason, /wait/); + assert.equal(db.listBoardPosts()[0].body, 'hello'); + db.addScore('flappy', 'visitor', 3); + db.addScore('flappy', 'visitor', 7); + assert.equal(db.getLeaderboard('flappy')[0].score, 7); + assert.equal(db.getPlayerBest('flappy', 'visitor'), 7); +}); diff --git a/test/games.test.js b/test/games.test.js new file mode 100644 index 0000000..4fba865 --- /dev/null +++ b/test/games.test.js @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { FlappyGame, FlappyService } from '../src/commands/flappy.js'; +import { PongGame, PongService } from '../src/commands/pong.js'; + +test('flappy engine advances deterministically', () => { + const game = new FlappyGame(() => 0); + game.tick(true); + assert.equal(game.y, 4.35); + for (let i = 0; i < 4; i++) game.tick(false); + assert.ok(game.y > 3 && game.y < 4, 'one flap should create a modest arc'); + assert.match(game.render(), /Score: 0/); +}); + +test('pong engine moves paddles and ball', () => { + const game = new PongGame(false); const x = game.ballX; + game.tick('left', 'up'); + assert.equal(game.leftY, 3); + assert.equal(game.ballX, x - 1); +}); + +test('pong bot can miss when its reaction is delayed', () => { + const game = new PongGame(true, () => 0); + game.ballY = 0; game.rightY = 6; + game.tick('left', 'stay'); + assert.equal(game.rightY, 6); +}); + +test('live flappy consumes Space and exits on q', () => { + let handler; let finished = ''; let writes = 0; + const service = new FlappyService({ addScore() {}, getLeaderboard() { return []; }, getPlayerBest() { return 0; } }); + const ctx = { username: 'visitor', channel: { destroyed: false, write() { writes++; } }, tui: { + setInputHandler(value) { handler = value; }, finishLive(value) { finished = value; } + } }; + assert.equal(service.start(ctx), '__LIVE__'); + assert.equal(typeof handler, 'function'); + const initialWrites = writes; handler(' '); + assert.equal(writes, initialWrites, 'key repeat must not advance extra physics frames'); + handler('q'); + assert.match(finished, /exited/); +}); + +test('live flappy accepts restart after game over', () => { + let handler; let writes = 0; + const service = new FlappyService({ addScore() {}, getLeaderboard() { return []; }, getPlayerBest() { return 0; } }); + const ctx = { username: 'visitor', channel: { destroyed: false, write() { writes++; } }, tui: { + setInputHandler(value) { handler = value; }, finishLive() {} + } }; + service.start(ctx); + const before = writes; + const state = service.live.get(ctx); + state.game.over = true; + handler('r'); + assert.notEqual(service.live.get(ctx).game, state.game); + assert.ok(writes > before); + handler('q'); +}); + +test('live pong consumes arrow keys and exits on q', () => { + let handler; let finished = ''; + const service = new PongService({ addScore() {}, getLeaderboard() { return []; }, getPlayerBest() { return 0; } }); + const ctx = { username: 'visitor', channel: { destroyed: false, write() {} }, tui: { + setInputHandler(value) { handler = value; }, finishLive(value) { finished = value; } + } }; + assert.equal(service.start(ctx, 'bot'), '__LIVE__'); + handler('\x1b[A'); handler('\x1b[B'); handler('q'); + assert.match(finished, /left/); +}); diff --git a/test/pages.test.js b/test/pages.test.js new file mode 100644 index 0000000..da246f7 --- /dev/null +++ b/test/pages.test.js @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { pages, pageMap } from '../src/pages/index.js'; + +test('page registry resolves aliases to their canonical module', () => { + for (const page of pages) for (const alias of page.aliases || []) assert.equal(pageMap.get(alias), page); +}); + +test('page registry is ordered and contains unique canonical pages', () => { + const canonical = [...new Set(pageMap.values())]; + assert.deepEqual(canonical.map(page => page.name), pages.slice().sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.name.localeCompare(b.name)).map(page => page.name)); +});