Compare commits

..

1 Commits

Author SHA1 Message Date
branch-loop 869266f991 we love to sniff farts!!!!! 2026-07-27 03:05:54 +01:00
43 changed files with 0 additions and 1820 deletions
-7
View File
@@ -1,7 +0,0 @@
node_modules/
.env
data/config.json
data/host.key
data/portfolio.sqlite*
data/dashboard-token
*.log
-138
View File
@@ -1,138 +0,0 @@
# 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 <page>` 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.
-77
View File
@@ -1,77 +0,0 @@
# SSH Portfolio
A public, anonymous Node.js SSH server that turns a terminal into a customizable portfolio. Visitors connect with:
```sh
ssh -p 2222 localhost
```
## Quick start
```sh
npm install
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.
Because this is an intentionally open service, run it in a container or restricted account, use a dedicated port, and do not expose private data through configured pages.
## Owner notifications
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 <server>`.
## 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 <server>`.
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 <message>` — leave a note.
- `board delete <id>` — 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. 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 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. 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.
-15
View File
@@ -1,15 +0,0 @@
{
"persona": { "name": "Your Name", "tagline": "A short line about you", "location": "Earth", "email": "you@example.com" },
"theme": { "primary": "cyan", "accent": "yellow", "muted": "brightBlack" },
"pages": {
"about": { "title": "About", "lines": ["Tell visitors who you are.", "This file is deliberately easy to edit."] },
"projects": { "title": "Projects", "lines": ["Project One — what it does", "Project Two — what you learned"] },
"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 }
}
-18
View File
@@ -1,18 +0,0 @@
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'
}
}]
};
BIN
View File
Binary file not shown.
-549
View File
@@ -1,549 +0,0 @@
{
"name": "ssh-portfolio",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ssh-portfolio",
"version": "0.1.0",
"dependencies": {
"better-sqlite3": "^11.10.0",
"ssh2": "^1.16.0"
},
"bin": {
"ssh-portfolio": "src/server.js",
"ssh-portfolio-notify": "src/notify-client.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"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",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"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",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"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",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"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",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"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",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"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"
}
}
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "ssh-portfolio",
"version": "0.1.0",
"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",
"test": "node --test"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
"ssh2": "^1.16.0"
}
}
-42
View File
@@ -1,42 +0,0 @@
import * as home from './commands/home.js';
import * as help from './commands/help.js';
import * as pages from './commands/pages.js';
import * as open from './commands/open.js';
import * as contact from './commands/contact.js';
import * as chat from './commands/chat.js';
import * as clearCommand from './commands/clear.js';
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, 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);
if (!command) return `Unknown command: ${name}. Try “help”.`;
if (command.admin && !api.isAdmin(ctx)) return `Unknown command: ${name}. Try “help”.`;
if (pageMap.has(name) && !command.run) return runPage(ctx, name);
const result = command.run({ ...api, api, ctx, arg, config: api.config, pageMap, visibleCommands: commands });
if (typeof result === 'string' && result.startsWith('__PAGE__:')) return runPage(ctx, result.slice(9));
return result;
}
};
}
-2
View File
@@ -1,2 +0,0 @@
export const name = 'announce'; export const description = 'Broadcast an announcement'; export const admin = true;
export function run({ arg, api }) { if (!arg) return 'Usage: announce <message>'; api.broadcast(`OWNER: ${arg}`); api.sendNotification(`OWNER: ${arg}`); return 'Announcement sent.'; }
-29
View File
@@ -1,29 +0,0 @@
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 <message>';
return posts.map(post => `#${post.id} ${safeName(post.author)}: ${post.body}`).join('\n') + '\n\nPost with: board post <message>';
}
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 <short message>';
const id = Number(arg.slice(7));
if (!Number.isSafeInteger(id) || id < 1) return 'Usage: board delete <post id>';
return api.db.deleteBoardPost(id) ? `Deleted bulletin post #${id}.` : `No bulletin post #${id}.`;
}
if (!arg.startsWith('post ')) return 'Usage: board [list] | board post <short message>';
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;
}
-2
View File
@@ -1,2 +0,0 @@
export const name = 'chat'; export const description = 'Chat with the owner/visitors';
export function run({ arg, ctx, api }) { if (!arg) return 'Usage: chat <message>'; const author = api.isAdmin(ctx) ? `${api.config.persona?.name || 'Owner'} [owner]` : (ctx.username || 'visitor'); const line = `${author}: ${arg}`; api.broadcast(line); api.sendNotification(line); return api.ownerOnline() ? 'Message sent to the owner.' : 'Message posted; the owner is currently offline.'; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'clear'; export const description = 'Clear the screen';
export function run() { return ''; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'contact'; export const description = 'Show contact details';
export function run({ config }) { return `Email: ${config.persona?.email || 'not configured'}`; }
-70
View File
@@ -1,70 +0,0 @@
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); }
-10
View File
@@ -1,10 +0,0 @@
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 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');
}
-2
View File
@@ -1,2 +0,0 @@
export const name = 'home'; export const description = 'Return home';
export function run({ pageMap, config, ownerOnline, ctx, api }) { return pageMap.get('home').render({ config, ownerOnline, ctx, api }); }
-8
View File
@@ -1,8 +0,0 @@
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.`;
}
-2
View File
@@ -1,2 +0,0 @@
export const name = 'open'; export const description = 'Open a page';
export function run({ arg }) { return arg ? `__PAGE__:${arg}` : 'Usage: open <page>'; }
-9
View File
@@ -1,9 +0,0 @@
import { color } from '../terminal.js';
export const name = 'pages'; export const description = 'List available pages';
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.';
}
-119
View File
@@ -1,119 +0,0 @@
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 = Math.max(0, Math.min(this.height - 1, this.ballY + this.vy));
}
const bounce = (paddleY, direction, x) => {
const hit = nextY - (paddleY + 1);
if (Math.abs(hit) > 1) return false;
// Flat center returns are harder to exploit, so accelerate them slightly.
this.vx = direction * (Math.abs(hit) < 0.1 ? 1.25 : 1);
// The contact point controls the angle: corners produce the steepest deflection.
this.vy = hit * 1.2;
nextX = x;
return true;
};
if (this.vx < 0 && nextX <= 1) bounce(this.leftY, 1, 2);
if (this.vx > 0 && nextX >= this.width - 2) bounce(this.rightY, -1, 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] = '|'; }
const renderX = Math.round(this.ballX); const renderY = Math.round(this.ballY);
if (renderY >= 0 && renderY < this.height && renderX >= 0 && renderX < this.width) cells[renderY][renderX] = '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}`);
}
chooseMode(ctx) {
if (!ctx.tui) return 'Pong is interactive; connect with an SSH shell (without a remote command).';
let selected = 0;
const modes = [['BOT', 'Play against the computer'], ['ONLINE', 'Play against another connected visitor']];
const render = () => this.draw(ctx, `PONG\n\n${modes.map(([name, description], index) => `${index === selected ? '>' : ' '} ${name.padEnd(7)} ${description}`).join('\n')}`, 'UP/DOWN arrows: choose ENTER: select Q: cancel');
ctx.tui.setInputHandler(input => {
if (input.includes('\x03') || /q/i.test(input)) { this.stop(ctx, '\x1b[?25hPong cancelled.'); return true; }
if (input.includes('\x1b[A') || input.includes('\x1bOA')) selected = (selected + modes.length - 1) % modes.length;
if (input.includes('\x1b[B') || input.includes('\x1bOB')) selected = (selected + 1) % modes.length;
if (input.includes('\r') || input.includes('\n')) {
const result = this.start(ctx, modes[selected][0].toLowerCase());
if (result !== '__LIVE__') ctx.tui?.finishLive(`\x1b[?25h${result}`);
return true;
}
render(); return true;
});
render(); return '__LIVE__';
}
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 arg.trim() ? api.pong.start(ctx, arg.toLowerCase()) : api.pong.chooseMode(ctx); }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'quit'; export const description = 'Disconnect';
export function run({ ctx }) { ctx.channel?.end(); return ''; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'reload'; export const description = 'Reload configuration'; export const admin = true;
export function run({ api }) { api.reload(); return 'Configuration reloaded.'; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'theme'; export const description = 'Set runtime primary color'; export const admin = true;
export function run({ arg, config }) { if (!arg) return 'Usage: theme <color>'; config.theme.primary = arg; return `Primary color set to ${arg} (runtime only).`; }
-60
View File
@@ -1,60 +0,0 @@
import fs from 'node:fs';
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.`);
}
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);
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;
-14
View File
@@ -1,14 +0,0 @@
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 };
}
-66
View File
@@ -1,66 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import http from 'node:http';
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SSH monitor</title><style>
:root{color-scheme:dark;--bg:#0b1014;--panel:#121a20;--line:#26343e;--text:#e8f0f4;--muted:#8fa3af;--green:#65d69e;--red:#ff6b6b}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px ui-monospace,SFMono-Regular,Menlo,monospace}main{max-width:1200px;margin:auto;padding:28px 18px}header{display:flex;justify-content:space-between;align-items:end;margin-bottom:20px}h1,h2{margin:0}h1{font-size:22px}h2{font-size:14px;color:var(--muted);margin-bottom:12px}.status{color:var(--green)}.grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:16px;overflow:auto}.wide{grid-column:1/-1}table{width:100%;border-collapse:collapse;white-space:nowrap}th,td{text-align:left;padding:9px 8px;border-bottom:1px solid var(--line)}th{color:var(--muted);font-weight:400}button{font:inherit;color:var(--text);background:#1d2a33;border:1px solid #3b4c57;border-radius:5px;padding:5px 8px;cursor:pointer}button.danger{color:#ffb2b2;border-color:#703b3b}.empty{color:var(--muted);padding:18px 8px}.commands{max-width:520px;overflow:hidden;text-overflow:ellipsis}@media(max-width:800px){.grid{grid-template-columns:1fr}.panel{grid-column:1}header{align-items:start;gap:10px;flex-direction:column}}
</style></head><body><main><header><div><h1>SSH server monitor</h1><div id="updated" class="status">connecting…</div></div><div id="summary"></div></header><div class="grid"><section class="panel wide"><h2>ACTIVE CONNECTIONS</h2><div id="active"></div></section><section class="panel wide"><h2>RECENT CONNECTIONS & COMMANDS</h2><div id="history"></div></section><section class="panel wide"><h2>BANNED IPS</h2><div id="bans"></div></section></div></main><script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const age=t=>{const s=Math.max(0,Math.floor((Date.now()-t)/1000));return s<60?s+'s':s<3600?Math.floor(s/60)+'m':Math.floor(s/3600)+'h'};
const when=t=>t?new Date(t).toLocaleString():'—';
async function action(path,body){const message=path==='/api/ban'?'Ban '+body.ip+' and close its connections?':path==='/api/unban'?'Unban '+body.ip+'?':'Close this connection?';if(!confirm(message))return;const r=await fetch(path,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});if(!r.ok)alert((await r.json()).error||'Action failed');load()}
const table=(heads,rows)=>rows.length?'<table><thead><tr>'+heads.map(x=>'<th>'+x+'</th>').join('')+'</tr></thead><tbody>'+rows.join('')+'</tbody></table>':'<div class="empty">Nothing to show.</div>';
async function load(){try{const r=await fetch('/api/state');if(r.status===401)return;const d=await r.json();document.querySelector('#updated').textContent='live · updated '+new Date().toLocaleTimeString();document.querySelector('#summary').textContent=d.active.length+' active · '+d.history.length+' recent';document.querySelector('#active').innerHTML=table(['User','IP','SSH client','Connected','Commands','Actions'],d.active.map(s=>'<tr><td>'+esc(s.username)+(s.admin?' · admin':'')+'</td><td>'+esc(s.ip)+'</td><td>'+esc(s.client)+'</td><td>'+age(s.connectedAt)+'</td><td class="commands" title="'+esc(s.commands.join(' · '))+'">'+esc(s.commands.join(' · ')||'—')+'</td><td><button class="danger" data-action="disconnect" data-id="'+esc(s.id)+'">close</button> <button class="danger" data-action="ban" data-ip="'+esc(s.ip)+'">ban IP</button></td></tr>'));document.querySelector('#history').innerHTML=table(['User','IP','SSH client','Connected','Disconnected','Commands'],d.history.map(s=>'<tr><td>'+esc(s.username)+(s.admin?' · admin':'')+'</td><td>'+esc(s.ip)+'</td><td>'+esc(s.client)+'</td><td>'+when(s.connectedAt)+'</td><td>'+when(s.disconnectedAt)+'</td><td class="commands" title="'+esc(s.commands.join(' · '))+'">'+esc(s.commands.join(' · ')||'—')+'</td></tr>'));document.querySelector('#bans').innerHTML=table(['IP','Banned',''],d.bans.map(b=>'<tr><td>'+esc(b.ip)+'</td><td>'+when(b.bannedAt)+'</td><td><button data-action="unban" data-ip="'+esc(b.ip)+'">unban</button></td></tr>'))}catch(e){document.querySelector('#updated').textContent='offline · retrying'}}
document.addEventListener('click',e=>{const b=e.target.closest('button[data-action]');if(!b)return;const a=b.dataset;if(a.action==='disconnect')action('/api/disconnect',{id:a.id});else if(a.action==='ban')action('/api/ban',{ip:a.ip});else if(a.action==='unban')action('/api/unban',{ip:a.ip})});load();setInterval(load,3000);
</script></body></html>`;
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.' });
});
}
-112
View File
@@ -1,112 +0,0 @@
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(); }
}
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env node
import net from 'node:net';
const port = Number(process.env.NOTIFICATION_PORT || 7777);
const socket = net.createConnection({ host: '127.0.0.1', port }, () => console.log(`Listening for SSH portfolio notifications on localhost:${port}`));
socket.on('data', data => data.toString().split('\n').filter(Boolean).forEach(line => { try { const item = JSON.parse(line); if (item.message) console.log(`\n[ssh-portfolio] ${item.message}`); } catch {} }));
socket.on('error', err => { console.error(`Unable to connect to notification server: ${err.message}`); process.exitCode = 1; });
-13
View File
@@ -1,13 +0,0 @@
// 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'}.`;
}
-13
View File
@@ -1,13 +0,0 @@
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 }) {
const persona = config.persona || {};
return wrap((persona.about || 'Tell visitors who you are.') + '\n\n' + (persona.bio || 'This is your space on the internet.')).join('\n');
}
-13
View File
@@ -1,13 +0,0 @@
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 }) {
const p = config.persona || {};
const status = ownerOnline() ? color('green', 'Owner is online') : color(config.theme?.muted || 'brightBlack', 'Owner is offline');
return `${color(config.theme?.accent || 'yellow', `Welcome to ${p.name || 'my portfolio'}`)}\n\n${wrap(p.bio || 'You are exploring a portfolio over SSH. Type “help” to get started.').join('\n')}\n\n${status}\n\nCommands: home · pages · open <page> · chat · contact · clear · quit`;
}
-26
View File
@@ -1,26 +0,0 @@
import * as home from './home.js';
import * as about from './about.js';
import * as projects from './projects.js';
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];
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 || '<unnamed>').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])));
-12
View File
@@ -1,12 +0,0 @@
export const name = 'links';
export const title = 'Links';
export const order = 30;
export const description = 'External links and profiles';
// 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',
'Website: https://example.com'
]).join('\n');
}
-12
View File
@@ -1,12 +0,0 @@
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. config.pages.projects is a legacy content fallback.
export function render({ config }) {
return (config.pages?.projects?.lines || [
'Project One — what it does',
'Project Two — what you learned'
]).join('\n');
}
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import crypto from 'node:crypto';
import ssh2 from 'ssh2';
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' })); }
function isAdmin(ctx) { return ctx.admin === true; }
function count() { return sessions.size; }
function sendNotification(message) { for (const socket of notifications) socket.write(JSON.stringify({ type: 'notification', message }) + '\n'); }
function prompt(ctx) { return `${color(config.theme?.primary || 'cyan', 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin] ') : ''} `; }
function renderHome(ctx) {
const p = config.persona || {}; const width = Math.max(40, ctx.cols || 80); const title = `${p.name || 'Your Name'}${p.tagline || ''}`;
const content = router.home(ctx).split('\n');
return `${clear}${header(title, `${count()} connected`, width, config.theme?.primary, config.theme?.muted)}\r\n${color(config.theme?.muted || 'brightBlack', p.location || '')}\r\n\r\n${box('Portfolio', content, width, config.theme)}\r\n\r\n`;
}
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 = 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, 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, 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();
}
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; }
if (ctxAuth.method === 'publickey' && ctxAuth.key) {
const key = ctxAuth.key.data.toString('base64');
const allowed = (config.adminPublicKeys || []).some(entry => String(entry).trim().split(/\s+/).includes(key));
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', () => { 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}`));
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);
-27
View File
@@ -1,27 +0,0 @@
export const ESC = '\x1b[';
export const colors = { cyan: 36, yellow: 33, green: 32, magenta: 35, blue: 34, white: 37, red: 31, brightBlack: 90 };
export const color = (name, text) => `\x1b[${colors[name] || colors.cyan}m${text}\x1b[0m`;
export const clear = '\x1b[0m\x1b[2J\x1b[3J\x1b[H';
export const crlf = text => String(text).replace(/\r?\n/g, '\r\n');
export const visibleLength = text => String(text).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').length;
export const truncate = (text, width) => Array.from(String(text)).slice(0, Math.max(0, width)).join('');
export function header(left, right, width, leftColor = 'cyan', rightColor = 'brightBlack') {
const safeWidth = Math.max(30, width || 80);
const safeRight = truncate(right, Math.max(0, safeWidth - 2));
const safeLeft = truncate(left, Math.max(1, safeWidth - safeRight.length - 2));
return `${color(leftColor, safeLeft)}${' '.repeat(Math.max(1, safeWidth - safeLeft.length - safeRight.length))}${color(rightColor, safeRight)}`;
}
export function box(title, lines, width, theme = {}) {
const outer = Math.max(30, width || 80); const inner = outer - 4;
const primary = theme.primary || 'cyan'; const muted = theme.muted || 'brightBlack';
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 = 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)}`);
}
export function wrap(text, width = 78) {
const out = []; for (const paragraph of String(text).split('\n')) { if (!paragraph) { out.push(''); continue; } let line = ''; for (const word of paragraph.split(/\s+/)) { if (line && visibleLength(line) + visibleLength(word) + 1 > width) { out.push(line); line = word; } else line += (line ? ' ' : '') + word; } out.push(line); } return out;
}
-57
View File
@@ -1,57 +0,0 @@
import { color, crlf } from './terminal.js';
export function configureTerminal(stream, title) {
stream.write('\x1b[?2004h\x1b[?7h\x1b[?25h\x1b[?1h\x1b=');
stream.write(`\x1b]2;${String(title).replace(/[\x00-\x1f\x7f]/g, '')}\x07`);
}
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 => {
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 && !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(); 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 => {
if (!state.history.length) return;
state.historyIndex = Math.max(0, Math.min(state.history.length, state.historyIndex + direction));
state.input = state.historyIndex === state.history.length ? '' : state.history[state.historyIndex]; state.cursor = state.input.length; redrawInput();
};
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) {
const sequence = ['\x1b[A', '\x1bOA', '\x1b[B', '\x1bOB', '\x1b[C', '\x1bOC', '\x1b[D', '\x1bOD'].find(item => key.startsWith(item));
if (sequence) { if (sequence.endsWith('A')) history(-1); else if (sequence.endsWith('B')) history(1); else if (sequence.endsWith('C')) move(1); else move(-1); key = key.slice(sequence.length); continue; }
const char = key[0]; key = key.slice(1);
if (char === '\r' || char === '\n') { if (char === '\r' && key[0] === '\n') key = key.slice(1); submit(); }
else if (char === '\x03' || (char === '\x04' && !state.input)) channel.end();
else if (char === '\x7f' || char === '\b') { if (state.cursor > 0) { state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor); state.cursor--; redrawInput(); } }
else if (char === '\x01') move(-state.cursor); else if (char === '\x05') move(state.input.length - state.cursor);
else if (char >= ' ') insert(char);
}
});
channel.on('close', () => { state.closed = true; onClose?.(); });
return { showHome, notify, redrawInput, updateHeader, setInputHandler, finishLive, state };
}
-23
View File
@@ -1,23 +0,0 @@
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));
});
-15
View File
@@ -1,15 +0,0 @@
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'));
-19
View File
@@ -1,19 +0,0 @@
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);
});
-95
View File
@@ -1,95 +0,0 @@
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('pong paddle contact changes trajectory by hit position', () => {
const game = new PongGame(false);
game.leftY = 4; game.ballX = 2; game.ballY = 4; game.vx = -1; game.vy = 0;
game.tick('left', 'stay');
assert.equal(game.vx, 1);
assert.ok(game.vy < 0, 'upper-edge contact should send the ball upward');
});
test('pong flat returns accelerate slightly', () => {
const game = new PongGame(false);
game.leftY = 4; game.ballX = 2; game.ballY = 5; game.vx = -1; game.vy = 0;
game.tick('left', 'stay');
assert.equal(game.vx, 1.25);
});
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/);
});
test('pong mode picker selects online with arrow keys and enter', () => {
let handler; let finished = ''; let screen = '';
const service = new PongService({ addScore() {}, getLeaderboard() { return []; }, getPlayerBest() { return 0; } });
const ctx = { username: 'visitor', channel: { destroyed: false, write(value) { screen = value; } }, tui: {
setInputHandler(value) { handler = value; }, finishLive(value) { finished = value; }
} };
assert.equal(service.chooseMode(ctx), '__LIVE__');
assert.match(screen, /BOT/); assert.match(screen, /ONLINE/);
handler('\x1b[B'); handler('\r');
assert.match(finished, /Waiting for another player/);
});
-12
View File
@@ -1,12 +0,0 @@
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));
});