A minimal prompt sharing platform. Pastebin for prompts.
Anyone can paste a prompt and get a shareable link. Public prompts are discoverable via homepage and search. Unlisted prompts are accessible only by direct URL.
Prompts are stored as plain text but rendered as Markdown in the view page. The copy button always copies raw text, preserving portability.
Token count: Display exact token count as "X tokens (OpenAI)" on detail page. Calculated server-side using tiktoken cl100k_base encoding and stored in database (since prompts are immutable).
- Publish: Paste content, add title, get a link. Optional: description, custom slug, source attribution, unlisted visibility.
- Discover: Browse public prompts on homepage, search by keyword, sort by recent or popular.
- View: Open a prompt URL, read content, copy to clipboard.
- No authentication or user accounts
- No editing or deleting prompts (immutable once created)
- No versioning
- No comments or ratings
- No tags or categories
Prompts are immutable after creation. There is no UPDATE operation. This simplifies the data model, eliminates conflict resolution, and ensures URLs always return the same content. The FTS index only needs INSERT/DELETE triggers since content never changes.
- Go (standard library HTTP server)
- SQLite with FTS5 for full-text search
- golang-migrate for database migrations (embedded SQL files)
- Server-side rendered HTML templates embedded in binary via
//go:embed - All frontend assets served locally (no external CDN):
- Theme CSS - Look for
macos9.cssinbackend/handlers/assets/. If that file isn't provided at build time, you must instead embed the official 98.css stylesheet and reference it from the template. Do not invent a custom theme; the fallback must be the canonical 98.css file. Only focus your CSS effort on app-specific layout inapp.css. - highlight.js for syntax highlighting
- Custom markdown renderer (server-side)
- Theme CSS - Look for
- Token counting: server-side using tiktoken-go with cl100k_base encoding (stored in DB on creation)
backend/
├── cmd/server/main.go # Entry point, signal handling
├── handlers/
│ ├── handlers.go # HTTP handlers, middleware, router
│ ├── templates/ # Go HTML templates (embedded via go:embed)
│ │ └── base.html # Main template with all views
│ └── assets/ # Static assets (embedded via go:embed)
│ ├── macos9.css # Theme CSS (if not present, use 98.css instead)
│ ├── app.css # App-specific layout styles
│ ├── highlight.min.js # Syntax highlighting
│ └── ChiKareGo2.woff2 # Chicago-style bitmap font (optional)
└── store/
├── store.go # Database operations, validation
└── migrations/ # SQL migration files (embedded)
e2e/
├── tests/ # Playwright test files
└── playwright.config.ts
tests/
└── integration_test.go # Go integration tests (HTTP endpoints)
| Field | Type | Constraints |
|---|---|---|
| id | int64 | primary key, auto-increment |
| slug | string | unique, 3-50 chars, alphanumeric + hyphens |
| title | string | required, max 200 chars |
| description | string | optional, max 500 chars |
| content | string | required, max 100,000 chars |
| visibility | string | "public" (default) or "unlisted" |
| source_url | string | optional, max 2048 chars, must be valid HTTP/HTTPS URL |
| source_title | string | optional, max 200 chars |
| views | int64 | default 0, incremented on each view |
| token_count | int | calculated on creation using tiktoken cl100k_base |
| created_at | timestamp | auto-set on creation |
- If provided: use as-is if available, error if taken
- If empty: auto-generate 6-char random lowercase alphanumeric
- Reserved slugs (cannot be used):
new,api,health,metrics - Validation: 3-50 characters, must start with a lowercase letter, followed by lowercase letters, numbers, or hyphens
- Valid examples:
my-prompt,gpt4-system-prompt,a1b2c3 - Invalid examples:
1-starts-with-number,UPPERCASE,has_underscore,ab(too short)
- Public: appears in homepage listings, appears in search results, accessible by direct URL
- Unlisted: never appears in listings or search, accessible only by direct URL
Security note: Unlisted prompts are obscured, not secured. The 6-character slug provides ~31 bits of entropy. Determined attackers could enumerate slugs. Do not use unlisted visibility for sensitive content.
Create a new prompt.
Request:
{
"title": "Required title",
"content": "Required prompt content",
"description": "Optional description",
"slug": "optional-custom-slug",
"visibility": "public",
"source_url": "https://example.com/original",
"source_title": "Original Article"
}Response: 201 Created with full prompt object
Errors:
400 Bad Request: empty title, empty content, invalid slug format, field too long409 Conflict: slug already exists
Search public prompts using full-text search.
Query parameters:
q(required, search query)limit(int, default 50, max 100)offset(int, default 0)
Response: 200 OK with array of matching prompt summaries
Errors:
400 Bad Request: empty or missing query
Search behavior: FTS5 search across title, description, and content. User queries are escaped to prevent FTS operator injection. Ordered by relevance. Only public prompts returned.
Get a single prompt as JSON by appending .json to any prompt URL. Works for both public and unlisted prompts.
Response: 200 OK with full prompt object
Side effect: Increments view count synchronously before returning response.
Errors:
404 Not Found: slug does not exist
Health check endpoint.
Response: 200 OK
{"status": "healthy", "database": "connected"}Error Response: 500 Internal Server Error if database unreachable
Prometheus metrics in text format.
Metrics:
prompts_created_total(counter)prompt_views_total(counter)http_requests_total(counter)http_errors_total(counter)
Server-side rendered HTML templates with retro desktop OS theme styling.
Look for macos9.css in backend/handlers/assets/. If that file is present, use it for the Mac OS 9 Platinum theme. If not provided at build time, embed the official 98.css stylesheet instead. Do not invent a custom theme—use one of these two options. Focus custom CSS effort only on app-specific layout in app.css.
Theme: Classic desktop OS aesthetic (Mac OS 9 or Windows 98 depending on theme CSS) with:
- Fixed menu bar at top (Apple menu with "About" dialog)
- Window chrome with title bar, control buttons
- Finder-style list view with sortable columns
- Classic system fonts (Chicago-style bitmap font if available, otherwise system default)
- Beveled buttons, inset fields, raised panels
Color Scheme: Classic system palette
- Surface gray for window backgrounds
- White content areas
- Blue selection highlight
- Classic border styling (light/dark edges for 3D effect)
/– Homepage (browse and search)/new– Create prompt/{slug}– View prompt (HTML)/{slug}.json– View prompt (JSON)
The frontend is a single-page application using History API for navigation.
View containers: Three mutually exclusive <div> elements with IDs:
view-list– Homepage view (Finder-style list)view-create– Create prompt form (dialog window)view-detail– View prompt detail (document window)
View switching: Use CSS class .hidden (display: none). Only one view visible at a time.
Core JavaScript functions:
handleRoute()– Parsewindow.location.pathname, show appropriate view, fetch datanavigate(path)– Update URL viahistory.pushState(), callhandleRoute()- Listen for
popstateevent to handle browser back/forward
State: Store current prompt data in a module-level variable for copy functionality.
- Product name: PromptBin
- Menu bar: Apple menu (left), app name "PromptBin" (right), current time
- About dialog: "Built by Cleric" with dynamic copyright year
- No "demo" language
Layout: Finder-style window with list view
Components:
- Window toolbar with "New" button and search box
- List header with sortable columns: Name, Modified, Tokens
- List body with alternating row colors
- Footer showing item count
Empty state:
- Centered message in list area
- "New" button remains available
Prompt rows show:
- Document icon (folded corner style)
- Title (clickable, selection highlight on hover)
- Modified date
- Token count
Mobile: Hide date column to maximize title width (Name + Tokens only)
Design: Mac OS 9 dialog window style
Element IDs:
createContent– Prompt textarea (monospace, inset field)createTitle– Title inputcreatePublic– Visibility checkboxcharCount– Live character count display
Layout:
- Content group - Label + textarea with character count
- Title input - With label
- Source URL input - Optional, with label
- Visibility checkbox - "List publicly" (checked by default)
- Action buttons - Cancel (left), Save (right, default style)
On success: Navigate to /{slug} view, form resets
Design: Document window with metadata bar and action buttons
Element IDs (for testing):
detailTitle– Prompt title (in window title bar)detailContent– Rendered markdown content areatokenCount– Token count in metadata barcopyButton– Copy to clipboard buttoncopyText– Text that changes to "copied!" after copy
Layout (top to bottom):
- Title bar - Window chrome with prompt title
- Metadata bar - Created date, slug link, token count, source (if provided)
- Content area - Scrollable, rendered Markdown with syntax highlighting
- Action bar - Back button (left), Raw/Formatted toggle, Copy button (right)
- History API router (pushState/popstate)
- Search triggers on Enter key
- Copy to clipboard using
navigator.clipboard.writeText()(copies raw content, not rendered HTML) - Markdown rendering: Server-side with highlight.js for code blocks
- Token count: Display
token_countfrom server (calculated with tiktoken) - Live character count on create form
- Menu bar clock updates every minute
- Mobile-first
- Content textarea comfortable on mobile
- Date column hidden on narrow screens (<600px)
- Window fills available height
- prompts table with all prompt fields
- prompts_fts FTS5 virtual table for full-text search (title, description, content), synced via INSERT/DELETE triggers
- Unique index on slug
- Indexes for efficient listing queries (public prompts by recency and popularity)
Use log/slog with configurable format (LOG_FORMAT: text or json) and level (LOG_LEVEL: debug, info, warn, error).
Log every:
- HTTP request: method, path, status, duration_ms
- Database operation: operation name, duration_ms, relevant context
- Error: operation context, error message, identifiers
GET /health verifies database connectivity by executing a simple query. Returns 200 if healthy, 500 if not.
Prometheus format at GET /metrics. Track: prompts created, prompt views, HTTP requests, HTTP errors.
Environment variables with defaults:
| Variable | Default | Description |
|---|---|---|
| PORT | 8080 | HTTP server port |
| DATABASE_PATH | ./data/prompts.db | SQLite database file path |
| LOG_FORMAT | text | Logging format (text or json) |
| LOG_LEVEL | info | Logging level |
| ALLOWED_ORIGIN | http://localhost:8080 | CORS allowed origin (set to production domain in deployment) |
- Runtime: Go
- Plan: starter (required for persistent disk)
- Persistent disk mounted at /data for SQLite
- Health check: /health
- Auto-deploy on push to main
- Build command:
make test && make build - Start command:
./bin/promptbin
- Run all tests (
make test) - Build binary (
make build) - Deploy
- Server starts and runs database migrations automatically
- Health check must pass before traffic routes
Migrations use golang-migrate with embedded SQL files:
- Location:
backend/store/migrations/ - Execution: Migrations run automatically on server startup
- Behavior:
- Fresh databases start from version 0 and apply all migrations
- Existing databases apply only pending migrations
- Data is preserved during schema changes
Migration files follow the naming convention NNN_description.up.sql and NNN_description.down.sql.
The Makefile centralizes build configuration, especially CGO flags required for SQLite FTS5:
| Target | Description |
|---|---|
make run |
Run the server locally (migrations run automatically) |
make test |
Run all Go tests with verbose output |
make test-e2e |
Run Playwright E2E tests |
make build |
Build the production binary to bin/promptbin |
make clean |
Remove build artifacts |
make help |
Show available targets |
CGO Requirement: All Go commands must be run with CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" to enable SQLite full-text search. The Makefile handles this automatically—always use Make targets instead of running go commands directly.
First Principle: Each behavior should be tested exactly once, at the appropriate layer.
Tests exist to catch regressions. Testing the same behavior at multiple layers adds maintenance cost without value. Choose the right layer:
| Layer | Purpose | What to Test |
|---|---|---|
| Store (unit) | Business logic | Validation rules, database operations, edge cases |
| Handler (integration) | HTTP wiring | Status codes, JSON parsing, middleware, routing |
| E2E Go | Full stack | Happy path with real HTTP server |
| E2E Playwright | Browser | User journeys requiring a real browser |
Anti-pattern to avoid: Testing validation at both store AND handler layers. If Store.CreatePrompt() validates empty titles, don't also test empty titles in TestCreatePromptHandler. The handler just calls the store—test it once.
Test business logic and database operations. This is where validation lives.
Location: backend/store/store_test.go
What to test:
- All validation rules (empty title, empty content, field length limits, slug format, URL validation)
- Database operations (create, get, list, search)
- Edge cases (duplicate slugs, reserved slugs, FTS operator escaping)
- Pagination and sorting behavior
- Visibility filtering (unlisted exclusion)
What NOT to test: HTTP concerns—that's the handler layer's job.
Test HTTP-specific behavior using httptest.
Location: backend/handlers/handlers_test.go
What to test:
- HTTP status codes (one happy path per endpoint)
- JSON request parsing (malformed JSON, missing Content-Type)
- Middleware (CORS headers, security headers, body size limits, panic recovery)
- Response format (correct Content-Type, JSON structure)
What NOT to test: Validation logic that lives in the store—one test proving 400 for store validation errors is enough.
Test HTTP endpoint behavior with a real server.
Location: tests/integration_test.go
What to test:
- HTTP endpoints return correct status codes and content
- SSR templates render correctly
- Search API pagination works
- Error handling (404 for missing prompts)
Keep minimal: These test HTTP wiring, not user flows.
Test real user journeys through the browser UI.
Location: e2e/tests/
Setup:
- Start Go server before tests (
playwright.config.tswebServer option) - Use in-memory database (
:memory:) for fast, isolated runs - Reuse existing server in development (
reuseExistingServer: !process.env.CI)
Test Philosophy:
- Test actual user flows through the UI (click buttons, fill forms)
- Never call APIs directly - that's what integration tests are for
- Test the happy path for each user journey
- One error case is enough
- Avoid testing UI details that don't affect functionality
- Keep the suite small and fast
- Create prompt and view it - Full create flow, verify detail page
- Create unlisted prompt - Visibility toggle works
- Copy to clipboard - Browser clipboard API works
- Search works - FTS integration works end-to-end
- Validation requires title and content - One error case
- Homepage branding - Visual identity correct
- Security headers on HTML - CSP, X-Frame-Options, etc.
- Security headers on API - Headers present on JSON responses
- No CSP violations - Normal usage doesn't trigger CSP errors
- XSS escaped - Malicious input displays as text
- Large request rejected - 413 for oversized payloads
A well-structured test suite for this app should have approximately:
| Layer | Expected Count | Rationale |
|---|---|---|
| Store | ~30 | All validation + database operations |
| Handler | ~15 | HTTP concerns only, not validation |
| E2E Go | ~4 | Prove wiring works |
| Playwright | ~11 | User journeys + security |
| Total | ~60 |
If handler tests exceed ~15, you're likely duplicating store tests. Refactor.
- Increment synchronously on every page view (HTML or JSON)
- Used for "popular" sorting
- NOT displayed in UI (no vanity metrics)
When no slug provided:
- Generate 6 random lowercase alphanumeric characters
- Check for collision
- Retry up to 3 times if collision
- Error if still colliding (extremely rare)
All errors return JSON with {"error": "message"}. Validation errors include field-specific details in a details object.
Restrict to configured origin. Set Access-Control-Allow-Origin to the value of ALLOWED_ORIGIN environment variable. Allow Content-Type header. Handle OPTIONS preflight. Reject requests from other origins.
On SIGINT/SIGTERM: stop accepting new connections, finish in-flight requests, close database, exit cleanly.
This is a simple, public prompt-sharing app. Security measures focus on preventing XSS, clickjacking, and basic abuse—not protecting sensitive data.
Multiple layers prevent XSS attacks:
- Input validation - Reject overly long strings at API layer
- Output encoding - All user content is HTML-escaped before rendering (
escapeHtml()function) - Content Security Policy - Restricts script sources to self only
- No innerHTML with raw user data - Content is escaped, then rendered as markdown
The escapeHtml() function converts <, >, &, ", ' to HTML entities. This happens before any other processing, so even if someone submits <script>alert('xss')</script>, it displays as literal text.
Field constraints are defined in the Data Model section. Additional security considerations:
source_url: must be valid HTTP/HTTPS URL (rejectjavascript:,data:,file:schemes)- FTS query escaping: User search queries are escaped to prevent FTS5 operator injection
All HTTP responses include (via middleware):
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originContent-Security-Policy(see below)
The CSP header restricts what resources can load:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
Key restrictions:
- Scripts and styles only from self (all assets served locally - no external CDN)
- No external images except data: URIs
- Cannot be framed (
frame-ancestors 'none') - Forms can only submit to self
Trade-off: 'unsafe-inline' is required for inline scripts and styles. This is acceptable for this app's threat model.
Why no CDN? All frontend assets (macos9.css, highlight.js) are embedded in the binary and served locally. This provides:
- No external dependencies or potential supply chain vulnerabilities
- Works offline / air-gapped environments
- Simpler, stricter CSP
- Consistent behavior regardless of CDN availability
Note: SRI is not applicable since all assets are served locally from the same origin. The assets are embedded in the Go binary at compile time, ensuring integrity through the build process.
- Maximum request body size: 150KB (reject before JSON parsing with
413 Payload Too Large) - This prevents memory exhaustion from large payloads
For a simple app, rate limiting is optional. If needed:
- POST /api/prompts: 10 requests per minute per IP
- GET endpoints: 100 requests per minute per IP
- Return
429 Too Many RequestswithRetry-Afterheader
- Set
Access-Control-Allow-Originto the configuredALLOWED_ORIGIN(not*in production) - Allow only
GET,POST,OPTIONSmethods - Allow only
Content-Typeheader