Compare commits
1 Commits
main
..
!nigger-538
| Author | SHA1 | Date | |
|---|---|---|---|
| dda352a35b |
@@ -1,7 +0,0 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/config.json
|
||||
data/host.key
|
||||
data/portfolio.sqlite*
|
||||
data/dashboard-token
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}]
|
||||
};
|
||||
Binary file not shown.
Generated
-549
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.'; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.'; }
|
||||
@@ -1,2 +0,0 @@
|
||||
export const name = 'clear'; export const description = 'Clear the screen';
|
||||
export function run() { return ''; }
|
||||
@@ -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'}`; }
|
||||
@@ -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); }
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 }); }
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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>'; }
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -1,2 +0,0 @@
|
||||
export const name = 'quit'; export const description = 'Disconnect';
|
||||
export function run({ ctx }) { ctx.channel?.end(); return ''; }
|
||||
@@ -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.'; }
|
||||
@@ -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).`; }
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@@ -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(); }
|
||||
}
|
||||
@@ -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; });
|
||||
@@ -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'}.`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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])));
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user