Download> Draft ? This proposal was generated by AI and may not have been reviewed for accuracy.
Gemini Capsule Server Proposal
Overview
Add an optional Gemini protocol server to BinktermPHP that lets each BBS user publish a personal Gemini capsule at gemini://host/home/username/. The BBS itself exposes a directory page at gemini://host/ listing all users with published capsules. A new Gemini Capsule WebDoor provides a browser-based gemtext editor with live preview and publish/draft controls.
The feature is strictly opt-in ? operators start the daemon only if they want to expose Gemini. Most core BBS installs are unaffected.
URL Scheme
| Path | Content |
|---|---|
| gemini://host/ | BBS home page ? directory of users with published capsules |
| gemini://host/home/username/ | User capsule ? serves index.gmi if published, otherwise auto-generated file listing |
| gemini://host/home/username/filename.gmi | Specific capsule file (only if published) |
The /home/ prefix is used instead of the traditional tilde (~) to avoid conflicts with Apache/nginx tilde URL handling.
Architecture
Database
New table gemini_capsule_files (migration v1.10.7_gemini_capsule_files.sql):
CREATE TABLE IF NOT EXISTS gemini_capsule_files (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
content TEXT NOT NULL DEFAULT '',
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_gemini_capsule_user_filename
ON gemini_capsule_files(user_id, filename);
CREATE INDEX IF NOT EXISTS idx_gemini_capsule_user_id
ON gemini_capsule_files(user_id);
CREATE INDEX IF NOT EXISTS idx_gemini_capsule_published
ON gemini_capsule_files(user_id, is_published);
Filename validation: /^[a-zA-Z0-9_\-]+\.(gmi|gemini)$/ ? enforced in the WebDoor API. Max content size: 512 KB per file.
Gemini Daemon (scripts/gemini_daemon.php)
A standalone PHP daemon mirroring telnet/telnet_daemon.php in structure:
-
Bootstrap: `vendor/autoload.php` + `src/functions.php`, standard `parseArgs()` helper
-
CLI flags: `--host`, `--port`, `--pid-file`, `--log-file`, `--daemon`, `--help`
-
Config: `GEMINI_BIND_HOST` (default `0.0.0.0`), `GEMINI_PORT` (default `1965`) via `Config::env()`
-
TLS: Auto-generates a self-signed certificate in `data/gemini/` on first run using PHP `openssl_*` functions. CN is derived from `SITE_URL`. Gemini uses Trust-On-First-Use (TOFU), so CA-signed certs are unnecessary but can be substituted.
-
Server socket: `stream_socket_server("ssl://host:port", ...)` ? PHP handles the TLS handshake on accept
-
Connections: `pcntl_fork()` per accepted connection; parent reaps children with SIGCHLD; SIGTERM/SIGINT triggers graceful shutdown
-
PID file: `data/run/gemini_daemon.pid`
-
Log file: `data/logs/gemini_daemon.log`
Request Handling
Each child process:
1. Reads up to 1026 bytes until \r\n (Gemini request line, max 1024 bytes per spec)
2. Validates gemini:// scheme; returns 59 Bad Request if malformed
3. Routes based on path (see URL scheme table above)
4. Writes response and closes connection
Response Format
<STATUS> <META>\r\n[body]
Status codes used:
- 20 text/gemini; charset=utf-8 ? success with gemtext body
- 51 Not Found ? missing user, unpublished file, or invalid path
Home Page
Queries users with at least one published file, sorted by username. Served as gemtext:
# BBS Name ? Gemini Capsule Directory
Welcome! These BBS users have published their Gemini capsules here.
## Capsules
=> gemini://host/home/alice/ alice
=> gemini://host/home/bob/ bob
User Index (/home/username/)
-
Looks up user by username (case-insensitive)
-
If `index.gmi` is published: serves it directly
-
Otherwise: auto-generates a directory listing of all published files
User File (/home/username/filename)
-
Validates filename against the allowed pattern
-
Returns file content if the file exists and is published
-
MIME type: `text/gemini` for `.gmi`/`.gemini`, `text/plain` otherwise
WebDoor Editor (public_html/webdoors/gemini-capsule/)
A split-pane browser-based editor following the gemini-browser WebDoor pattern.
webdoor.json ? door ID gemini-capsule, no storage feature (uses dedicated DB table directly via WebDoorSDK\getDatabase()).
api.php actions (all require auth + door enabled check):
| Action | Method | Description |
|---|---|---|
| file_list | GET | List user's files with publish status |
| file_load | GET ?filename= | Load file content |
| file_save | POST {filename, content} | Upsert file |
| file_delete | POST {filename} | Delete file |
| file_publish | POST {filename, is_published} | Toggle published flag |
| capsule_url | GET | Return gemini://host/home/username/ |
UI layout:
+-------------------------------------------+
| Gemini Capsule ? Your capsule URL: [link] |
+----------------+---------------------------+
| FILES | [new filename] [Create] |
| > index.gmi ? |---------------------------|
| about.gmi ? | [textarea ? gemtext src] |
| | |
| |---------------------------|
| | [live preview panel] |
| | |
| | [Save] [Publish] [Delete] |
+----------------+---------------------------+
-
? = published, ? = draft
-
Preview updates live as user types (300ms debounce) using `renderGemtext()` from `gemini-browser/js/gemtext.js` (shared, not copied)
-
IIFE module pattern matching gemini-browser's `app.js`
-
Dark terminal aesthetic matching `gemini-browser/css/gemini.css`
Configuration
.env Variables
# Gemini Capsule Server (optional ? only needed if running the daemon)
# GEMINI_BIND_HOST=0.0.0.0
# GEMINI_PORT=1965
Docker
docker-compose.yml port mapping:
- "${GEMINI_PORT:-1965}:1965"
docker/supervisord.conf program entry with autostart=false (opt-in):
[program:gemini_daemon]
command=php /var/www/html/scripts/gemini_daemon.php --host=0.0.0.0
autostart=false
autorestart=true
user=binkterm
directory=/var/www/html
stdout_logfile=/var/www/html/data/logs/gemini_daemon.log
stderr_logfile=/var/www/html/data/logs/gemini_daemon_error.log
scripts/restart_daemons.sh
Conditional restart (only if already running), following the existing telnetd pattern.
Files
New Files
| Path | Description |
|---|---|
| database/migrations/v1.10.7_gemini_capsule_files.sql | DB migration |
| scripts/gemini_daemon.php | Gemini TLS server daemon |
| public_html/webdoors/gemini-capsule/webdoor.json | WebDoor manifest |
| public_html/webdoors/gemini-capsule/index.html | Editor UI |
| public_html/webdoors/gemini-capsule/api.php | Editor backend |
| public_html/webdoors/gemini-capsule/js/app.js | Editor JS |
| public_html/webdoors/gemini-capsule/css/style.css | Editor CSS |
Modified Files
| Path | Change |
|---|---|
| .env.example | Add GEMINI_BIND_HOST, GEMINI_PORT (commented) |
| .env.docker.example | Add GEMINI_PORT=1965 |
| docker-compose.yml | Add port 1965 mapping |
| docker/supervisord.conf | Add [program:gemini_daemon] |
| scripts/restart_daemons.sh | Add conditional gemini daemon restart |
| docs/UPGRADING_1.8.3.md | Document opt-in setup |
| public_html/sw.js | Bump service worker cache version |
Upgrade / Setup
Operators who want capsule hosting:
-
Run `php scripts/setup.php` (applies the `v1.10.7` migration)
-
Add to `.env`:
GEMINI_PORT=1965
GEMINI_BIND_HOST=0.0.0.0
-
Enable the Gemini Capsule WebDoor in Admin ? WebDoors
-
Start the daemon:
php scripts/gemini_daemon.php --daemon
-
On Linux, port 1965 requires root or `authbind`. A common alternative is to bind to a high port (e.g., 11965) and use `iptables` or `firewalld` to forward 1965 ? 11965.
Out of Scope
-
Public echo areas (not included)
-
Binary file hosting (text/gemini and text/plain only)
-
Per-user storage quotas (future consideration)
-
Client certificate authentication (Gemini feature, not implemented here)
-
Custom domains per user
|