remove preact and create setup wizard

This commit is contained in:
2026-06-01 10:10:36 -04:00
parent b3364447a1
commit ebe0920f89
53 changed files with 818 additions and 4226 deletions

View File

@@ -4,9 +4,5 @@
data
tmp
**/node_modules
**/dist
**/.vite
coverage
*.log

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Required for deployed passkeys and device authorization URLs.
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
# Optional SMTP settings. The bundled Compose file defaults to its local
# Mailpit service so notifications can be inspected during initial deployment.
CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit
CAIRNQUIRE_EMAIL_SMTP_PORT=1025
CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local
# Optional: INFO, DEBUG, WARN, or ERROR.
CAIRNQUIRE_LOG_LEVEL=INFO

6
.gitignore vendored
View File

@@ -1,11 +1,7 @@
data/
tmp/
node_modules/
dist/
coverage/
.env
.DS_Store
*.log
apps/web/node_modules/
apps/web/dist/
apps/server/bin/

View File

@@ -11,9 +11,9 @@
| Document | Decision |
|----------|----------|
| [ADR-001: Tech Stack](adrs/adr-001-tech-stack.md) | Go backend + Preact frontend |
| [ADR-001: Tech Stack](adrs/adr-001-tech-stack.md) | Go server with embedded web assets |
| [ADR-002: Sync Protocol](adrs/adr-002-sync-protocol.md) | SimpleSync content-addressed protocol |
| [ADR-003: SSR Strategy](adrs/adr-003-ssr-strategy.md) | Go templates with Preact hydration |
| [ADR-003: SSR Strategy](adrs/adr-003-ssr-strategy.md) | Go templates with embedded browser scripts |
| [ADR-004: Auth Strategy](adrs/adr-004-auth-strategy.md) | Passkeys primary, passwords fallback |
| [ADR-005: Storage Strategy](adrs/adr-005-storage-strategy.md) | libsql + content-addressed filesystem |
| [ADR-006: Email Strategy](adrs/adr-006-email-strategy.md) | Postmark gateway, plain-text only |
@@ -84,8 +84,7 @@ cairnquire/
│ ├── security-specification.md
│ └── deployment-guide.md
├── apps/
── server/ # Go application
│ └── web/ # Preact frontend
── server/ # Go application, templates, and static assets
├── packages/
│ └── protocol/ # Shared types
├── docker-compose.yml

View File

@@ -1,53 +1,54 @@
# ADR-001: Go Backend with Preact Frontend
# ADR-001: Go Server with Embedded Web Assets
## Status
Accepted
## Context
We need to choose a backend language/framework and frontend technology for a documentation platform with these requirements:
- Ultra-fast performance
- Minimal external dependencies
- No vendor lock-in
- Battle-tested security
- Server-side rendering for non-interactive pages
- Offline-capable web client
We need a documentation platform with fast page delivery, minimal external
dependencies, secure server-side rendering, and a straightforward deployment
artifact.
## Decision
**Backend**: Go 1.22+ with standard library plus minimal, well-audited packages
**Frontend**: Preact 10 with TypeScript, Vite build system
Use Go 1.24+ with the standard library plus minimal, audited packages. The Go
binary serves HTML templates and embeds its CSS, images, and small browser
scripts. There is no separate SPA, Node dependency tree, or frontend build
pipeline.
Browser scripts progressively add editing, offline caching, WebSocket updates,
comments, and authentication interactions to server-rendered pages.
## Consequences
### Positive
- Go's standard library is comprehensive and security-audited
- Single static binary deployment — no runtime dependencies
- Preact is 10KB (vs 40KB+ React) with identical API
- Go's `html/template` provides safe SSR with auto-escaping
- TypeScript gives type safety without runtime overhead
- Both ecosystems have excellent supply-chain security (Go modules with checksums, npm audit)
- One application binary and one build toolchain
- Go `html/template` provides auto-escaped server rendering
- Embedded assets deploy with the server binary
- Fewer supply-chain dependencies and fewer moving parts in CI
- Pages remain readable before browser scripts execute
### Negative
- Go template syntax is less expressive than JSX
- WebSocket sync logic must be implemented manually (no framework like Socket.io)
- Preact ecosystem smaller than React (mitigated by using mostly native APIs)
- Template syntax is less expressive than JSX
- Browser behavior uses small framework-free modules
- WebSocket and offline behavior must be maintained directly
## Alternatives Considered
### Separate Preact SPA
- **Pros**: Component model, client-side routing, TypeScript ecosystem
- **Cons**: Adds Node tooling, package dependencies, a second build pipeline,
and duplicate UI surfaces
- **Rejected**: The Go-served UI already covers the application surface
### Rust + Axum
- **Pros**: Memory safety guarantees, excellent performance
- **Cons**: Longer compile times, smaller talent pool, steeper learning curve for team
- **Rejected**: Go's pragmatic balance of safety and velocity better fits project timeline
### Deno Fresh
- **Pros**: Island architecture, native TypeScript, no build step
- **Cons**: Newer ecosystem, fewer audited libraries, Deno runtime less battle-tested than Go
- **Rejected**: Go's maturity and deployment simplicity preferred
- **Cons**: Longer compile times and steeper learning curve
- **Rejected**: Go better fits the project deployment model
### Next.js / React Server Components
- **Pros**: Mature ecosystem, built-in SSR
- **Cons**: Heavy bundle size, complex build system, RSC lock-in, many dependencies
- **Rejected**: Violates "minimal dependencies" and "no vendor lock-in" principles
- **Cons**: Heavy build system and more runtime complexity
- **Rejected**: Violates the minimal dependency goal
## References
- Go Security Policy: https://go.dev/security
- Preact Size Comparison: https://bundlephobia.com/package/preact@10.19.3
- Go `html/template`: https://pkg.go.dev/html/template

View File

@@ -1,67 +1,55 @@
# ADR-003: SSR Strategy - Go Templates with Preact Hydration
# ADR-003: SSR Strategy - Go Templates with Embedded Browser Scripts
## Status
Accepted
## Context
The application needs to serve document pages that are readable without JavaScript (for performance and accessibility), while providing rich interactivity (comments, editing, search) when JavaScript is available. We need a server-side rendering strategy that works with our Go backend and Preact frontend.
Document pages must be readable quickly and safely while still supporting
editing, authentication, comments, offline caching, and real-time updates.
## Decision
**Strategy C**: Go's `html/template` renders initial HTML for all pages. Preact hydrates interactive components on the client.
Go `html/template` renders complete HTML pages. The server embeds and serves
small framework-free browser scripts for progressive enhancement.
### Implementation Details
1. **Server Rendering**:
- Go handlers query database for document content
- Markdown parsed to HTML server-side (Goldmark)
- `html/template` renders full page with document HTML embedded
- No JavaScript required for reading
- Go handlers query document and account data
- Goldmark parses Markdown to HTML server-side
- `html/template` renders complete pages
- Pages are readable before JavaScript executes
2. **Client Hydration**:
- Preact components mount into designated DOM containers
- Comments, search, editor are Preact islands
- Static content remains server-rendered HTML
- `preact-iso` handles client-side navigation between pages
3. **Data Passing**:
- Initial state embedded as JSON in `<script type="application/json">` tags
- Preact reads initial state during hydration, avoids extra API calls
- CSP nonce applied to all inline scripts
2. **Browser Enhancement**:
- Embedded scripts handle editing, comments, authentication, offline cache,
service-worker behavior, and WebSocket updates
- Native browser APIs are preferred over a client framework
- There is no separate SPA route or frontend build pipeline
## Consequences
### Positive
- First contentful paint is instant (pure HTML)
- SEO-friendly without extra complexity
- Graceful degradation: works without JS
- Go templates are auto-escaping XSS-safe
- No V8/JS runtime embedded in Go binary
- Smaller client bundle (only interactive components)
- Fast first contentful paint
- Auto-escaped templates
- One deployable application artifact
- Small browser payloads and no Node build dependency
- Interactive features stay close to their HTTP endpoints
### Negative
- Some code duplication: markdown parsing logic in both Go and JS (for preview)
- Template syntax is verbose compared to JSX
- Hydration can cause flicker if not carefully managed
- Client state must reconcile with server-rendered HTML
- Browser modules need direct DOM event handling
- Shared UI abstractions require deliberate maintenance
- More complex browser interactions need focused integration tests
## Alternatives Considered
### Go Templates Only (No Preact)
- **Pros**: Zero client JS, maximum performance
- **Cons**: No interactive features (comments, real-time sync, search)
- **Rejected**: Doesn't meet collaboration requirements
### Separate Hydrated SPA
- **Pros**: Component framework and client-side routing
- **Cons**: Duplicates UI paths and introduces a second toolchain
- **Rejected**: The server-rendered UI already supports the required behavior
### Embedded JS Runtime (QuickJS/V8)
- **Pros**: True isomorphic rendering, JSX templates
- **Cons**: Adds C dependency, complex build, larger binary, security surface area
- **Rejected**: Violates minimal dependency principle
### Preact SSR via WASM
- **Pros**: Same templates on server and client
- **Cons**: WASM overhead, complex build pipeline, slower than Go templates
- **Rejected**: Go templates are faster and simpler
### Embedded JS Runtime
- **Pros**: Isomorphic rendering
- **Cons**: Larger binary and additional security surface
- **Rejected**: Go templates are simpler
## References
- Go html/template security: https://pkg.go.dev/html/template
- Preact hydration: https://preactjs.com/guide/v10/server-side-rendering
- Islands Architecture: https://jasonformat.com/islands-architecture/
- Go `html/template`: https://pkg.go.dev/html/template

View File

@@ -12,7 +12,7 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Browser │ │ File Editor │ │ Mobile / PWA │ │
│ │ (Preact SPA) │ │ (VS Code, │ │ (Responsive Web) │ │
│ │ (Go HTML UI) │ │ (VS Code, │ │ (Responsive Web) │ │
│ │ │ │ Vim, etc.) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────────────┬─────────────┘ │
│ │ │ │ │
@@ -142,7 +142,7 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
8. Render HTML: AST → html/template with layout
9. Response: HTML with CSP nonce, Preact hydration marker
9. Response: HTML with embedded static assets and progressive enhancement scripts
```
## Data Flow: File Sync

View File

@@ -9,7 +9,7 @@
- [ ] Initialize monorepo structure
- `apps/server/` with Go module
- `apps/web/` with Vite + Preact + TypeScript
- embedded templates and static assets under `apps/server/internal/httpserver/`
- `packages/protocol/` with protobuf schema
- `docs/` with existing documentation
@@ -53,10 +53,10 @@
- Security headers middleware
- MIME type detection
- [ ] Initial frontend
- Preact app setup with Vite
- Basic routing (preact-iso)
- Hydration entry point
- [ ] Initial browser UI
- Go template routes
- Embedded framework-free browser scripts
- Progressive enhancement entry points
- CSS design system foundation
## Acceptance Criteria

View File

@@ -12,7 +12,7 @@
- Comment threading (parent/child)
- Line/section anchoring (hash-based)
- Comment resolution (mark as resolved)
- Comment display in Preact UI
- Comment display in the Go-served browser UI
- [ ] Notification system
- Notification queue in database

View File

@@ -9,7 +9,7 @@
- [ ] Unit tests
- Go: >80% coverage for all packages
- Preact: Component testing with uvu
- Browser behavior: integration testing for embedded scripts
- Mock external dependencies (email, filesystem)
- [ ] Integration tests

View File

@@ -2,8 +2,8 @@
## 2026-04-28
1. The plan and ADRs repeatedly describe a single Go binary, while Milestone 1 also requires `apps/web/` as a separate Vite/Preact app. The current implementation treats the web app as build-time static assets served by the Go binary.
1. Resolved: the separate Vite/Preact SPA was removed. The Go binary serves templates and embedded static assets directly.
2. Milestone 1 asks for `packages/protocol/` with a protobuf schema, but the sync ADR and protocol spec describe JSON messages over WebSocket. The protocol package currently preserves both a `.proto` placeholder and TypeScript-facing JSON model notes.
3. ADR-005 stores content under `data/files/...`, while Milestone 2 examples use `store/...`. The implementation follows ADR-005 and uses `data/files`.
4. The frontend stack still requires a Node package install step, but local automation is now centralized in `mise` tasks and the lockfile-backed `pnpm` workflow rather than ad hoc commands.
4. Resolved: the deployment and development toolchain no longer require Node or pnpm.
5. The foundation milestone specifies `golang-migrate`, but `go-libsql` integration constraints need a clean validation path. A lightweight internal migrator is used now so the project can move forward.

View File

@@ -29,7 +29,7 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
- [x] Markdown-to-HTML pipeline with most common extensions enabled
- [x] Static file serving for attachments
- [x] Basic Dockerfile and docker-compose.yml
- [x] Dev mode with `air` live reload + Vite HMR proxy
- [x] Dev mode with `air` live reload
- [x] Miller column document browser with folder navigation
- [x] `index.md` default rendering for folders
- [x] Content-addressed attachment storage with secure serving
@@ -47,7 +47,7 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
**Notes:**
- Mermaid diagrams and math blocks now render client-side after server-rendered markdown loads.
- Dev mode uses `air` for Go live reload and Vite proxy for frontend HMR.
- Dev mode uses `air` for Go live reload.
---
@@ -209,8 +209,8 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
┌─────────────────────────────────────────────────────────────┐
│ Client Browser │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Preact UI │ │ File Sync │ │ Search Index │ │
│ │ (hydrated) │ │ (IndexedDB) │ │ (FTS5) │ │
│ │ Go HTML UI │ │ File Sync │ │ Search Index │ │
│ │ + scripts │ │ (IndexedDB) │ │ (FTS5) │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
@@ -259,17 +259,14 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
### Frontend (Preact + TypeScript)
### Browser UI
| Component | Library | Justification |
| --------- | ------------------------------ | --------------------------------------- |
| Framework | `preact` (10KB) | React API, tiny bundle, fast hydration |
| Routing | `preact-iso` | Lightweight isomorphic routing |
| Markdown | `marked` + `micromark` | Server+client compatible |
| Search | `flexsearch` | Client-side full-text, zero server load |
| Component | Technology | Justification |
| ----------- | ------------------------------ | -------------------------------------- |
| Rendering | Go `html/template` | Server-rendered, auto-escaped HTML |
| Interaction | Embedded browser scripts | Small progressive-enhancement modules |
| Styling | Native CSS + custom properties | No build-time CSS processing needed |
| Icons | Inline SVG | No icon font dependency |
| Build | `vite` | Fast HMR, modern output, minimal config |
### Infrastructure
@@ -347,12 +344,8 @@ cairnquire/
│ │ │ ├── markdown/ # Markdown rendering pipeline
│ │ │ ├── realtime/ # WebSocket hub
│ │ │ └── store/ # Content-addressed filesystem
│ │ ├── static/ # Static assets
│ │ ├── static/ # Embedded static assets
│ │ └── templates/ # Go HTML templates
│ └── web/ # Preact frontend application
│ ├── src/
│ ├── index.html
│ └── vite.config.ts
├── content/ # Markdown source files
├── data/ # SQLite database & attachments
└── packages/

View File

@@ -8,6 +8,8 @@ Browser users authenticate with passkeys or password fallback. Successful login
Endpoints:
- `GET /setup`
- `POST /api/setup`
- `GET /login`
- `GET /account`
- `POST /api/auth/register/password`
@@ -21,7 +23,12 @@ Endpoints:
- `POST /api/auth/passkeys/login/begin`
- `POST /api/auth/passkeys/login/finish?challengeId=...`
The first registered user bootstraps as `admin`. Later public registrations become `viewer`; role changes are admin-managed from the account screen. Public registration endpoints only create new accounts. Adding or changing credentials on an existing account requires an authenticated browser session.
On an empty database, the one-time `/setup` wizard creates the first `admin`
account and records whether public signup is enabled. Later public
registrations are accepted only when that setting is enabled and always become
`viewer`; role changes are admin-managed from the account screen. Public
registration endpoints only create new accounts. Adding or changing
credentials on an existing account requires an authenticated browser session.
## API Tokens
@@ -64,7 +71,8 @@ This is intentionally inspired by OAuth device authorization, but it is not a ge
## Route Protection
- Public: health, static assets, document reads, login/account entry pages, password/passkey begin-login/register endpoints, device start/token polling.
- Setup-only until configured: initial setup page and endpoint.
- Public: health, static assets, document reads, login/account entry pages, password/passkey begin-login/register endpoints when signups are enabled, device start/token polling.
- Session or bearer token required: auth profile/logout, edit/save APIs, uploads, sync/content APIs, device approval.
- Admin role required: admin APIs and API token management.

View File

@@ -4,269 +4,120 @@
- Docker 24.0+
- Docker Compose 2.20+
- 1GB RAM minimum (2GB recommended)
- 10GB disk space
- TLS certificate (Let's Encrypt or custom)
- A reverse proxy that terminates TLS for the public hostname
- A writable checkout directory for `./data` and `./content`
## Quick Start
## Fresh Deployment
```bash
# Clone repository
git clone https://github.com/your-org/cairnquire.git
cd cairnquire
# Copy and edit configuration
cp .env.example .env
# Edit .env with your domain, email, Postmark API key
# Start services
docker-compose up -d
# Verify health
curl https://your-domain.com/health
```
## Configuration
Edit `.env` and set the HTTPS origin that users will open in their browser:
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DOMAIN` | Yes | — | Public domain name |
| `DB_PATH` | No | `/data/db.sqlite` | Database file path |
| `FILESTORE_PATH` | No | `/data/files` | Content-addressed file storage |
| `NOTES_PATH` | No | `/notes` | Watched markdown directory |
| `POSTMARK_API_KEY` | Yes | — | Postmark server API token |
| `POSTMARK_FROM` | No | `notifications@DOMAIN` | From email address |
| `SESSION_SECRET` | Yes | — | 32-byte hex for session signing |
| `ENV` | No | `production` | `development` or `production` |
### Docker Compose
```yaml
version: "3.8"
services:
app:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/data
- ./notes:/notes:ro
environment:
- DOMAIN=${DOMAIN}
- DB_PATH=/data/db.sqlite
- FILESTORE_PATH=/data/files
- NOTES_PATH=/notes
- POSTMARK_API_KEY=${POSTMARK_API_KEY}
- SESSION_SECRET=${SESSION_SECRET}
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=100m
# Optional: Traefik for auto-HTTPS
traefik:
image: traefik:v3.0
command:
- "--api.insecure=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
labels:
- "traefik.enable=true"
restart: unless-stopped
```dotenv
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
```
## Production Checklist
### Pre-Deployment
- [ ] Change default `SESSION_SECRET` (generate: `openssl rand -hex 32`)
- [ ] Configure `POSTMARK_API_KEY` with valid token
- [ ] Set `DOMAIN` to public hostname
- [ ] Configure DNS A/AAAA records
- [ ] Set up SPF and DKIM for email domain
- [ ] Review `.env` file — no default secrets
### Security
- [ ] TLS 1.3 enforced (Traefik or reverse proxy)
- [ ] HSTS header configured
- [ ] Firewall rules: only 80/443 open
- [ ] Fail2ban or rate limiting on SSH
- [ ] Automatic security updates enabled
- [ ] Backup encryption key stored securely
### Monitoring
- [ ] Health check endpoint accessible
- [ ] Log rotation configured
- [ ] Disk space alerting (>80%)
- [ ] Memory usage alerting
- [ ] Uptime monitoring (external)
## Backup Procedures
### Automated Backup
Start the application and verify the local container health endpoint:
```bash
#!/bin/bash
# backup.sh
BACKUP_DIR="/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/md-hub-$TIMESTAMP.tar.gz"
# Create backup
tar czf "$BACKUP_FILE" \
-C /data \
db.sqlite \
files/
# Encrypt (optional)
gpg --symmetric --cipher-algo AES256 "$BACKUP_FILE"
rm "$BACKUP_FILE"
# Keep only last 30 days
find "$BACKUP_DIR" -name "md-hub-*.tar.gz.gpg" -mtime +30 -delete
docker compose up -d --build
docker compose ps
curl http://127.0.0.1:8080/health
```
**Cron**: `0 2 * * * /opt/cairnquire/backup.sh`
Then open `https://cairnquire.example.com/setup`. On an empty database the
first-run wizard:
### Restore from Backup
1. Creates the initial administrator account.
2. Sets its password.
3. Records whether visitors may create their own accounts.
4. Signs the administrator in.
The setup endpoint is only available until the first administrator has been
created. The signup setting can be changed later from the administrator's
account page.
## Environment Variables
The checked-in Compose file sets the container paths. For a normal single-host
deployment, only `CAIRNQUIRE_PUBLIC_ORIGIN` must be supplied in `.env`.
| Variable | Required | Compose default | Description |
|----------|----------|-----------------|-------------|
| `CAIRNQUIRE_PUBLIC_ORIGIN` | Yes | None | Exact external HTTPS origin. Passkeys and device-flow URLs depend on this value. |
| `CAIRNQUIRE_EMAIL_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. |
| `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. |
| `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. |
| `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. |
The server also accepts these lower-level overrides when it is run outside the
checked-in Compose deployment:
| Variable | Default | Description |
|----------|---------|-------------|
| `CAIRNQUIRE_SERVER_ADDR` | `:8080` | Listen address. |
| `CAIRNQUIRE_DATABASE_PATH` | `../../data/db.sqlite` | Local SQLite/libsql database path. |
| `CAIRNQUIRE_DATABASE_PRIMARY_URL` | Empty | Optional remote libsql primary URL for embedded-replica mode. |
| `CAIRNQUIRE_DATABASE_AUTH_TOKEN` | Empty | Optional auth token for the remote libsql primary. |
| `CAIRNQUIRE_CONTENT_SOURCE_DIR` | `../../content` | Writable Markdown source directory. |
| `CAIRNQUIRE_CONTENT_STORE_DIR` | `../../data/files` | Content-addressed attachment store. |
| `CAIRNQUIRE_CONFIG` | Empty | Optional JSON configuration file. Environment variables override it. |
| `CAIRNQUIRE_DEV_MODE` | `false` | Local-only auth shortcut. Never enable this on a deployed server. |
## Persistent Data
Compose mounts:
- `./data:/workspace/data` for the database and attachments
- `./content:/workspace/content` for editable Markdown source
Both mounts must remain writable because uploads, browser edits, and sync
writebacks modify them.
## Reverse Proxy
Expose container port `8080` through a TLS-terminating reverse proxy. Preserve
the original hostname and forwarded client address headers. The configured
`CAIRNQUIRE_PUBLIC_ORIGIN` must exactly match the external origin, including
`https://`.
## Backup And Restore
Stop writes or stop the app container before copying a fully consistent local
database backup:
```bash
# Stop application
docker-compose stop app
# Extract backup
tar xzf md-hub-20240115_020000.tar.gz -C /data
# Restart
docker-compose up -d app
# Verify
curl https://your-domain.com/health
docker compose stop app
tar czf cairnquire-backup.tgz data content
docker compose up -d app
```
## Upgrade Procedures
Restore by stopping the app, replacing `data` and `content` from the archive,
and starting the app again.
## Upgrade
### Minor Update (patch version)
```bash
docker-compose pull
docker-compose up -d
docker compose stop app
tar czf cairnquire-backup-before-upgrade.tgz data content
git pull
docker compose up -d --build
curl http://127.0.0.1:8080/health
```
### Major Update
1. Read release notes for breaking changes
2. Create backup: `./backup.sh`
3. Pull new image: `docker-compose pull`
4. Run database migrations (auto on startup)
5. Update configuration if needed
6. Deploy: `docker-compose up -d`
7. Verify health and functionality
8. Rollback if issues: restore from backup
Database migrations run automatically on startup.
## Troubleshooting
### Application Won't Start
```bash
# Check logs
docker-compose logs -f app
# Verify configuration
docker-compose exec app /server -check-config
# Test database
docker-compose exec app sqlite3 /data/db.sqlite ".tables"
docker compose logs -f app
docker compose ps
curl http://127.0.0.1:8080/health
```
### Sync Not Working
```bash
# Check file watcher
docker-compose exec app ls -la /notes
# Verify WebSocket
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
-H "Sec-WebSocket-Version: 13" \
https://your-domain.com/ws
```
### Email Not Sending
```bash
# Test Postmark connection
curl -X POST \
https://api.postmarkapp.com/server \
-H "X-Postmark-Server-Token: YOUR_TOKEN" \
-H "Accept: application/json"
# Check email queue
docker-compose exec app sqlite3 /data/db.sqlite \
"SELECT * FROM email_queue WHERE status='failed'"
```
## Scaling
### Vertical Scaling
- Increase container memory/CPU limits
- SQLite WAL mode handles concurrent reads well
- For heavy write loads, consider libsql replication
### Horizontal Scaling (Advanced)
```yaml
# Requires shared storage and load balancer
services:
app1:
# ... same config ...
environment:
- DB_PATH=/shared/db.sqlite
- FILESTORE_PATH=/shared/files
app2:
# ... same config ...
environment:
- DB_PATH=/shared/db.sqlite
- FILESTORE_PATH=/shared/files
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
ports:
- "80:80"
```
**Note**: Horizontal scaling requires:
- Shared filesystem (NFS, EFS)
- libsql replication or external database
- Sticky sessions for WebSocket
## Support
- **Documentation**: `/docs` route (when authenticated)
- **Health Check**: `GET /health`
- **Metrics**: `GET /metrics` (Prometheus format)
- **Logs**: `docker-compose logs -f app`
- **Issues**: https://github.com/your-org/cairnquire/issues
If passkey creation fails after deployment, confirm that the browser URL and
`CAIRNQUIRE_PUBLIC_ORIGIN` are the same HTTPS origin.

View File

@@ -408,7 +408,6 @@ RUN addgroup -g 1000 appgroup && \
# Read-only root filesystem
COPY --from=builder /app/server /server
COPY --from=builder /app/web/dist /web
# Data directory (writable)
RUN mkdir /data && chown appuser:appgroup /data

View File

@@ -37,7 +37,7 @@ Extend the existing web application so it works offline, supports concurrent edi
### Current State (Already Built)
- Server: Go HTTP + SQLite + file watcher + WebSocket hub
- Browser: Preact + IndexedDB cache + `navigator.onLine` detection
- Browser: Go-served HTML + embedded scripts + IndexedDB cache + `navigator.onLine` detection
- Documents served at `/:path*` with `index.md` default
- Real-time updates pushed via WebSocket when files change on disk

View File

@@ -1,10 +1,3 @@
FROM node:24-bookworm-slim AS web-build
WORKDIR /workspace/apps/web
COPY apps/web/package.json apps/web/package-lock.json* ./
RUN npm install
COPY apps/web/ ./
RUN npm run build
FROM golang:1.24-bookworm AS server-build
WORKDIR /workspace
RUN apt-get update && apt-get install -y --no-install-recommends build-essential ca-certificates && rm -rf /var/lib/apt/lists/*
@@ -20,7 +13,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
RUN useradd --create-home --shell /usr/sbin/nologin appuser
WORKDIR /workspace
COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire
COPY --from=web-build /workspace/apps/web/dist /workspace/web-dist
COPY content /workspace/content
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
USER appuser
@@ -29,7 +21,5 @@ ENV CAIRNQUIRE_SERVER_ADDR=:8080
ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files
ENV CAIRNQUIRE_WEB_DIST_DIR=/workspace/web-dist
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/cairnquire"]

View File

@@ -10,7 +10,7 @@ This repository now contains the Milestone 1 foundation scaffold:
- SQLite/libsql-oriented data layer with startup migrations
- Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates
- Content-addressed attachment storage with secure serving
- Preact + Vite frontend scaffold for later hydration-focused UI work
- Go-served HTML templates with embedded CSS and browser scripts
- Docker, Compose, CI, OpenAPI, and local development targets
## Quick Start
@@ -30,39 +30,41 @@ mise install
### Development Mode (with live reload)
Run both the Go server and Vite dev server with live reload:
Run the Go server with live reload:
```bash
mise run dev
```
This starts:
- Go server with `air` live reload on http://localhost:8080
- Vite dev server with HMR on http://localhost:5173
- The Go server proxies `/app/*` requests to Vite, so access the app at http://localhost:8080/app/
You can also run them separately in two terminals:
```bash
# Terminal 1 - Go server with live reload
mise run dev-server
# Terminal 2 - Vite dev server
mise run dev-web
```
This starts the Go server with `air` live reload on `http://localhost:8080`.
### Production Mode
Build the web app and run the Go server without Vite proxy:
Run the Go server:
```bash
mise run web-install
mise run web-build
mise run server-run
```
The server listens on `http://localhost:8080` by default.
### Fresh Server Deployment
The Compose deployment keeps its database and attachment store in `./data` and
its editable Markdown source in `./content`.
```bash
cp .env.example .env
# Edit CAIRNQUIRE_PUBLIC_ORIGIN in .env to match your HTTPS URL.
docker compose up -d --build
curl http://127.0.0.1:8080/health
```
Open `${CAIRNQUIRE_PUBLIC_ORIGIN}/setup` in a browser. The first-run wizard
creates the admin account and asks whether visitors may create accounts. The
setup endpoint closes after the first admin is created. An admin can change the
signup setting later from the account page.
### Common commands
```bash
@@ -82,18 +84,20 @@ Configuration can come from:
Supported variables:
- `CAIRNQUIRE_SERVER_ADDR`
- `CAIRNQUIRE_DATABASE_PATH`
- `CAIRNQUIRE_DATABASE_PRIMARY_URL`
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN`
- `CAIRNQUIRE_CONTENT_SOURCE_DIR`
- `CAIRNQUIRE_CONTENT_STORE_DIR`
- `CAIRNQUIRE_WEB_DIST_DIR`
- `CAIRNQUIRE_DEV_VITE_URL` - Set to proxy `/app/*` to Vite dev server (e.g. `http://localhost:5173`)
- `CAIRNQUIRE_LOG_LEVEL`
- `CAIRNQUIRE_PUBLIC_ORIGIN` - Required for deployment. Exact public HTTPS origin used for passkeys and device-flow URLs, for example `https://cairnquire.example.com`.
- `CAIRNQUIRE_SERVER_ADDR` - Listen address. Defaults to `:8080`.
- `CAIRNQUIRE_DATABASE_PATH` - Local SQLite/libsql path. Defaults to `../../data/db.sqlite` when running from `apps/server`.
- `CAIRNQUIRE_DATABASE_PRIMARY_URL` - Optional remote libsql primary URL for an embedded replica.
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
- `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store.
- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host. Set to an empty string in JSON config to disable delivery.
- `CAIRNQUIRE_EMAIL_SMTP_PORT` - SMTP port. Defaults to `1025`.
- `CAIRNQUIRE_EMAIL_FROM` - Notification sender address.
- `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`.
- `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server.
## Noted Gaps
- The docs describe both a single-binary system and a separate Vite/Preact app. This implementation keeps the Go server as the primary runtime and treats the web app as compiled static assets.
- The docs call for `golang-migrate`, but the current foundation uses an internal migration runner while `go-libsql` integration details are validated.
- The sync docs describe JSON messages while the Milestone 1 layout mentions a protobuf package; both are preserved in `packages/protocol/` pending a later protocol lock.

View File

@@ -100,6 +100,93 @@ func (r *Repository) CountAdmins(ctx context.Context) (int, error) {
return count, nil
}
func (r *Repository) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
var setupCompleted string
var signupsEnabled int
if err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(setup_completed_at, ''), signups_enabled
FROM instance_settings
WHERE id = 1
`).Scan(&setupCompleted, &signupsEnabled); err != nil {
return InstanceSettings{}, fmt.Errorf("get instance settings: %w", err)
}
return InstanceSettings{
SetupComplete: setupCompleted != "",
SignupsEnabled: signupsEnabled == 1,
}, nil
}
func (r *Repository) CompleteInitialSetup(ctx context.Context, user User, signupsEnabled bool) (User, error) {
now := time.Now().UTC()
if user.ID == "" {
user.ID = "user:" + user.Email
}
if user.DisplayName == "" {
user.DisplayName = user.Email
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return User{}, fmt.Errorf("begin initial setup: %w", err)
}
defer func() { _ = tx.Rollback() }()
var setupCompleted string
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(setup_completed_at, '')
FROM instance_settings
WHERE id = 1
`).Scan(&setupCompleted); err != nil {
return User{}, fmt.Errorf("get initial setup state: %w", err)
}
if setupCompleted != "" {
return User{}, fmt.Errorf("initial setup has already been completed")
}
var userCount int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
return User{}, fmt.Errorf("count users during initial setup: %w", err)
}
if userCount != 0 {
return User{}, fmt.Errorf("initial setup requires an empty user database")
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(RoleAdmin), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
return User{}, fmt.Errorf("create initial admin: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE instance_settings
SET setup_completed_at = ?, signups_enabled = ?, updated_at = ?
WHERE id = 1 AND setup_completed_at IS NULL
`, now.Format(time.RFC3339), boolInt(signupsEnabled), now.Format(time.RFC3339)); err != nil {
return User{}, fmt.Errorf("complete initial setup: %w", err)
}
if err := tx.Commit(); err != nil {
return User{}, fmt.Errorf("commit initial setup: %w", err)
}
return r.GetUserByEmail(ctx, user.Email)
}
func (r *Repository) UpdateSignupsEnabled(ctx context.Context, enabled bool) error {
result, err := r.db.ExecContext(ctx, `
UPDATE instance_settings
SET signups_enabled = ?, updated_at = ?
WHERE id = 1 AND setup_completed_at IS NOT NULL
`, boolInt(enabled), time.Now().UTC().Format(time.RFC3339))
if err != nil {
return fmt.Errorf("update signup setting: %w", err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return fmt.Errorf("initial setup is required")
}
return nil
}
func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
@@ -522,6 +609,13 @@ func nullString(value string) any {
return value
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func timePtrString(value *time.Time) any {
if value == nil {
return nil

View File

@@ -55,7 +55,34 @@ func NewService(repo *Repository, publicOrigin string) (*Service, error) {
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
}
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
return s.repo.GetInstanceSettings(ctx)
}
func (s *Service) CompleteInitialSetup(ctx context.Context, email, displayName, password string, signupsEnabled bool) (User, error) {
email = normalizeEmail(email)
if email == "" {
return User{}, fmt.Errorf("email is required")
}
if len(password) < 12 {
return User{}, fmt.Errorf("password must be at least 12 characters")
}
passwordHash, err := hashPassword(password)
if err != nil {
return User{}, err
}
return s.repo.CompleteInitialSetup(ctx, User{
Email: email,
DisplayName: strings.TrimSpace(displayName),
PasswordHash: passwordHash,
Role: RoleAdmin,
}, signupsEnabled)
}
func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) {
if err := s.requirePublicSignups(ctx); err != nil {
return User{}, err
}
email = normalizeEmail(email)
if email == "" {
return User{}, fmt.Errorf("email is required")
@@ -72,21 +99,11 @@ func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName,
if err != nil {
return User{}, err
}
userCount, err := s.repo.CountUsers(ctx)
if err != nil {
return User{}, err
}
normalizedRole := RoleViewer
if userCount == 0 {
normalizedRole = RoleAdmin
} else if strings.EqualFold(role, string(RoleViewer)) {
normalizedRole = RoleViewer
}
return s.repo.UpsertUser(ctx, User{
Email: email,
DisplayName: strings.TrimSpace(displayName),
PasswordHash: passwordHash,
Role: normalizedRole,
Role: RoleViewer,
})
}
@@ -113,23 +130,18 @@ func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAg
}
func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) {
if err := s.requirePublicSignups(ctx); err != nil {
return nil, "", err
}
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil {
if !isNoRows(err) {
return nil, "", err
}
userCount, err := s.repo.CountUsers(ctx)
if err != nil {
return nil, "", err
}
normalizedRole := RoleViewer
if userCount == 0 {
normalizedRole = RoleAdmin
}
user, err = s.repo.UpsertUser(ctx, User{
Email: normalizeEmail(email),
DisplayName: strings.TrimSpace(displayName),
Role: normalizedRole,
Role: RoleViewer,
})
if err != nil {
return nil, "", err
@@ -278,6 +290,43 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
return s.repo.ListUsers(ctx)
}
func (s *Service) UpdateSignupsEnabled(ctx context.Context, actor Principal, enabled bool) (InstanceSettings, error) {
if !Allows(actor, ScopeAdmin) {
return InstanceSettings{}, fmt.Errorf("admin role required")
}
if err := s.repo.UpdateSignupsEnabled(ctx, enabled); err != nil {
return InstanceSettings{}, err
}
return s.repo.GetInstanceSettings(ctx)
}
func (s *Service) EnsureDevUser(ctx context.Context) (User, error) {
const email = "dev@localhost"
settings, err := s.repo.GetInstanceSettings(ctx)
if err != nil {
return User{}, err
}
if !settings.SetupComplete {
return s.CompleteInitialSetup(ctx, email, "Development Admin", "development-only-password", true)
}
user, err := s.repo.GetUserByEmail(ctx, email)
if err == nil {
return user, nil
}
if !isNoRows(err) {
return User{}, err
}
return s.repo.UpsertUser(ctx, User{Email: email, DisplayName: "Development Admin", Role: RoleAdmin})
}
func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principal, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return Principal{}, err
}
return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil
}
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
if !Allows(actor, ScopeAdmin) {
return User{}, fmt.Errorf("admin role required")
@@ -453,6 +502,20 @@ func (s *Service) createSession(ctx context.Context, user User, ip, userAgent st
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil
}
func (s *Service) requirePublicSignups(ctx context.Context) error {
settings, err := s.repo.GetInstanceSettings(ctx)
if err != nil {
return err
}
if !settings.SetupComplete {
return fmt.Errorf("initial setup is required")
}
if !settings.SignupsEnabled {
return fmt.Errorf("signups are disabled")
}
return nil
}
func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal {
return Principal{
UserID: user.ID,

View File

@@ -28,13 +28,23 @@ func setupAuthTestService(t *testing.T) *Service {
return service
}
func setupInitialAdmin(t *testing.T, service *Service, signupsEnabled bool) User {
t.Helper()
user, err := service.CompleteInitialSetup(context.Background(), "admin@example.com", "Admin", "correct horse battery staple", signupsEnabled)
if err != nil {
t.Fatalf("CompleteInitialSetup() error = %v", err)
}
return user
}
func TestPasswordLoginCreatesValidSession(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", "editor")
user, err := service.CompleteInitialSetup(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", true)
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
t.Fatalf("CompleteInitialSetup() error = %v", err)
}
if user.Email != "dev@example.com" {
t.Fatalf("email = %q, want normalized", user.Email)
@@ -48,7 +58,7 @@ func TestPasswordLoginCreatesValidSession(t *testing.T) {
t.Fatal("expected session token")
}
if principal.Role != RoleAdmin {
t.Fatalf("role = %s, want first registered user to bootstrap as admin", principal.Role)
t.Fatalf("role = %s, want initial setup user to be admin", principal.Role)
}
validated, err := service.ValidateSessionToken(ctx, token)
@@ -64,10 +74,7 @@ func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
user := setupInitialAdmin(t, service, true)
expires := time.Now().UTC().Add(time.Hour)
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
if err != nil {
@@ -99,10 +106,7 @@ func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
user := setupInitialAdmin(t, service, true)
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
if err != nil {
t.Fatalf("StartDeviceFlow() error = %v", err)
@@ -132,10 +136,7 @@ func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
user := setupInitialAdmin(t, service, true)
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
t.Fatalf("ChangePassword() error = %v", err)
@@ -152,10 +153,7 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
user := setupInitialAdmin(t, service, true)
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
t.Fatal("expected demoting last admin to fail")
@@ -169,6 +167,7 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
setupInitialAdmin(t, service, true)
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
@@ -179,3 +178,32 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
t.Fatal("expected public passkey registration for existing account to fail")
}
}
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, false)
if admin.Role != RoleAdmin {
t.Fatalf("initial role = %s, want admin", admin.Role)
}
if _, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer"); err == nil || err.Error() != "signups are disabled" {
t.Fatalf("RegisterPasswordUser() error = %v, want signups are disabled", err)
}
principal := principalFromUser(admin, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
settings, err := service.UpdateSignupsEnabled(ctx, principal, true)
if err != nil {
t.Fatalf("UpdateSignupsEnabled() error = %v", err)
}
if !settings.SignupsEnabled {
t.Fatal("expected signups to be enabled")
}
viewer, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "admin")
if err != nil {
t.Fatalf("RegisterPasswordUser() after enabling signups error = %v", err)
}
if viewer.Role != RoleViewer {
t.Fatalf("public signup role = %s, want viewer", viewer.Role)
}
}

View File

@@ -92,6 +92,11 @@ type CreatedAPIKey struct {
Token string `json:"token"`
}
type InstanceSettings struct {
SetupComplete bool `json:"setupComplete"`
SignupsEnabled bool `json:"signupsEnabled"`
}
type DeviceCode struct {
ID string
UserID string

View File

@@ -12,10 +12,10 @@ type Config struct {
Server ServerConfig `json:"server"`
Database DatabaseConfig `json:"database"`
Content ContentConfig `json:"content"`
Web WebConfig `json:"web"`
Auth AuthConfig `json:"auth"`
Email EmailConfig `json:"email"`
LogLevel string `json:"logLevel"`
DevMode bool `json:"devMode"`
}
type ServerConfig struct {
@@ -33,11 +33,6 @@ type ContentConfig struct {
StoreDir string `json:"storeDir"`
}
type WebConfig struct {
DistDir string `json:"distDir"`
DevViteURL string `json:"devViteUrl"`
}
type AuthConfig struct {
PublicOrigin string `json:"publicOrigin"`
}
@@ -60,10 +55,6 @@ func Default() Config {
SourceDir: filepath.Join("..", "..", "content"),
StoreDir: filepath.Join("..", "..", "data", "files"),
},
Web: WebConfig{
DistDir: filepath.Join("..", "web", "dist"),
DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
},
Auth: AuthConfig{
PublicOrigin: "http://localhost:8080",
},
@@ -91,8 +82,6 @@ func Load() (Config, error) {
overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
overrideString(&cfg.Content.SourceDir, "CAIRNQUIRE_CONTENT_SOURCE_DIR")
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR")
overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL")
overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
@@ -102,6 +91,13 @@ func Load() (Config, error) {
}
overrideString(&cfg.Email.From, "CAIRNQUIRE_EMAIL_FROM")
overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL")
if devMode := os.Getenv("CAIRNQUIRE_DEV_MODE"); devMode != "" {
parsed, err := strconv.ParseBool(devMode)
if err != nil {
return Config{}, fmt.Errorf("parse CAIRNQUIRE_DEV_MODE: %w", err)
}
cfg.DevMode = parsed
}
if cfg.Server.Addr == "" {
return Config{}, fmt.Errorf("server.addr must not be empty")

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS instance_settings;

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS instance_settings (
id INTEGER PRIMARY KEY CHECK(id = 1),
setup_completed_at TEXT,
signups_enabled INTEGER NOT NULL DEFAULT 0 CHECK(signups_enabled IN (0, 1)),
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO instance_settings (id, setup_completed_at, signups_enabled, updated_at)
SELECT
1,
CASE WHEN EXISTS(SELECT 1 FROM users) THEN datetime('now') ELSE NULL END,
CASE WHEN EXISTS(SELECT 1 FROM users) THEN 1 ELSE 0 END,
datetime('now');

View File

@@ -271,6 +271,33 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
return &record, nil
}
func (r *Repository) ListAttachments(ctx context.Context) ([]AttachmentRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT hash, original_name, content_type, size_bytes, created_at
FROM attachments
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("list attachments: %w", err)
}
defer rows.Close()
var attachments []AttachmentRecord
for rows.Next() {
var record AttachmentRecord
var created string
if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created); err != nil {
return nil, fmt.Errorf("scan attachment: %w", err)
}
record.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse attachment created_at: %w", err)
}
attachments = append(attachments, record)
}
return attachments, rows.Err()
}
type SearchResult struct {
Path string `json:"path"`
Title string `json:"title"`

View File

@@ -15,6 +15,10 @@ type adminUserInput struct {
Role string `json:"role"`
}
type adminSettingsInput struct {
SignupsEnabled bool `json:"signupsEnabled"`
}
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
var input adminUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
@@ -95,7 +99,6 @@ func (s *Server) handleAdminWorkspace(w http.ResponseWriter, r *http.Request) {
"databasePath": s.config.Database.Path,
"documentCount": len(documents),
"userCount": len(users),
"webDistDir": s.config.Web.DistDir,
},
"documents": documents,
})
@@ -113,6 +116,30 @@ func (s *Server) handleAdminWorkspaceSync(w http.ResponseWriter, r *http.Request
})
}
func (s *Server) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (s *Server) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var input adminSettingsInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
settings, err := s.auth.UpdateSignupsEnabled(r.Context(), principal, input.SignupsEnabled)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (s *Server) normalizeAdminUserInput(input adminUserInput) (docs.UserRecord, bool) {
email := strings.TrimSpace(strings.ToLower(input.Email))
if email == "" {

View File

@@ -32,6 +32,10 @@ type apiTestServer struct {
}
func newAPITestServer(t *testing.T) *apiTestServer {
return newAPITestServerWithSetup(t, true)
}
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
t.Helper()
ctx := context.Background()
@@ -69,10 +73,13 @@ func newAPITestServer(t *testing.T) *apiTestServer {
if err != nil {
t.Fatalf("create auth service: %v", err)
}
user, err := authService.RegisterPasswordUser(ctx, "editor@example.com", "Editor", "very secure password", string(auth.RoleEditor))
var user auth.User
if setupComplete {
user, err = authService.CompleteInitialSetup(ctx, "editor@example.com", "Editor", "very secure password", false)
if err != nil {
t.Fatalf("create test user: %v", err)
}
}
syncRepo := sync.NewRepository(db.SQL())
handler, err := New(Dependencies{
@@ -81,7 +88,6 @@ func newAPITestServer(t *testing.T) *apiTestServer {
SourceDir: sourceDir,
StoreDir: storeDir,
},
Web: config.WebConfig{DistDir: filepath.Join(root, "web-dist")},
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
},
Logger: logger,
@@ -106,6 +112,54 @@ func newAPITestServer(t *testing.T) *apiTestServer {
}
}
func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
server := newAPITestServerWithSetup(t, false)
index := httptest.NewRecorder()
server.handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/", nil))
if index.Code != http.StatusSeeOther || index.Header().Get("Location") != "/setup" {
t.Fatalf("initial index response = %d %q, want redirect to /setup", index.Code, index.Header().Get("Location"))
}
setupPage := httptest.NewRecorder()
server.handler.ServeHTTP(setupPage, httptest.NewRequest(http.MethodGet, "/setup", nil))
if setupPage.Code != http.StatusOK || !bytes.Contains(setupPage.Body.Bytes(), []byte("Initial setup")) {
t.Fatalf("setup page response = %d %q", setupPage.Code, setupPage.Body.String())
}
setup := httptest.NewRecorder()
server.handler.ServeHTTP(setup, jsonRequest(http.MethodPost, "/api/setup", map[string]any{
"email": "owner@example.com",
"displayName": "Owner",
"password": "correct horse battery staple",
"signupsEnabled": false,
}))
if setup.Code != http.StatusCreated {
t.Fatalf("setup status = %d, want %d; body=%s", setup.Code, http.StatusCreated, setup.Body.String())
}
if len(setup.Result().Cookies()) == 0 {
t.Fatal("expected setup to create a browser session")
}
settings, err := server.auth.GetInstanceSettings(context.Background())
if err != nil {
t.Fatalf("GetInstanceSettings() error = %v", err)
}
if !settings.SetupComplete || settings.SignupsEnabled {
t.Fatalf("settings = %#v, want setup complete with signups disabled", settings)
}
register := httptest.NewRecorder()
server.handler.ServeHTTP(register, jsonRequest(http.MethodPost, "/api/auth/register/password", map[string]any{
"email": "viewer@example.com",
"displayName": "Viewer",
"password": "correct horse battery staple",
}))
if register.Code != http.StatusBadRequest || !bytes.Contains(register.Body.Bytes(), []byte("signups are disabled")) {
t.Fatalf("register response = %d %q, want disabled signup error", register.Code, register.Body.String())
}
}
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper()

View File

@@ -1,43 +0,0 @@
package httpserver
import (
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
)
func (s *Server) appFileServer() http.Handler {
if s.config.Web.DevViteURL != "" {
viteURL, err := url.Parse(s.config.Web.DevViteURL)
if err == nil {
proxy := httputil.NewSingleHostReverseProxy(viteURL)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = viteURL.Host
// Add back the /app/ prefix that was stripped by http.StripPrefix
req.URL.Path = "/app" + req.URL.Path
}
return proxy
}
}
files := http.FileServer(http.Dir(s.config.Web.DistDir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if cleanPath == "." {
cleanPath = "index.html"
}
target := filepath.Join(s.config.Web.DistDir, cleanPath)
if info, err := os.Stat(target); err == nil && !info.IsDir() {
files.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, filepath.Join(s.config.Web.DistDir, "index.html"))
})
}

View File

@@ -24,6 +24,13 @@ type passwordLoginRequest struct {
Password string `json:"password"`
}
type initialSetupRequest struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
SignupsEnabled bool `json:"signupsEnabled"`
}
type passkeyBeginRequest struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
@@ -67,17 +74,61 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
nextPath = "/account"
}
settings, err := s.auth.GetInstanceSettings(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "load registration settings")
return
}
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
Title: "Sign in",
WebEnabled: s.webEnabled,
BodyClass: "page-auth",
BodyTemplate: "login_content",
Data: struct {
Next string
}{Next: nextPath},
SignupsEnabled bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled},
})
}
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "load initial setup state")
return
}
if settings.SetupComplete {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, http.StatusOK, "setup.gohtml", layoutData{
Title: "Initial setup",
BodyClass: "page-auth",
BodyTemplate: "setup_content",
})
}
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req initialSetupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if _, err := s.auth.CompleteInitialSetup(r.Context(), req.Email, req.DisplayName, req.Password, req.SignupsEnabled); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "initial admin created; sign in to continue"})
return
}
setSessionCookie(w, r, token, principal.ExpiresAt)
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"principal": principal})
}
func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
@@ -86,7 +137,6 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
}
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
Title: "Account",
WebEnabled: s.webEnabled,
BodyClass: "page-account",
BodyTemplate: "account_content",
Data: principal,
@@ -377,7 +427,6 @@ func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request)
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
Title: "Approve device",
WebEnabled: s.webEnabled,
BodyClass: "page-auth",
BodyTemplate: "device_verify_content",
Data: struct {

View File

@@ -23,7 +23,6 @@ import (
type layoutData struct {
Title string
WebEnabled bool
BodyClass string
BodyTemplate string
Data any
@@ -98,7 +97,6 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
WebEnabled: s.webEnabled,
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
@@ -118,7 +116,6 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
WebEnabled: s.webEnabled,
BodyClass: "page-search",
BodyTemplate: "search_content",
Data: struct {
@@ -225,7 +222,6 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
Data: documentEditData{
@@ -259,7 +255,6 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
@@ -403,6 +398,15 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
}
func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
attachments, err := s.repository.ListAttachments(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "list attachments"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"attachments": attachments})
}
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
@@ -414,7 +418,6 @@ func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string,
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderTemplate(w, status, "error.gohtml", layoutData{
Title: http.StatusText(status),
WebEnabled: s.webEnabled,
BodyClass: "page-error",
BodyTemplate: "error_content",
Data: errorData{

View File

@@ -76,6 +76,33 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
})
}
func (s *Server) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "lookup initial setup state"})
return
}
if settings.SetupComplete || isSetupAllowedPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
writeJSONWithStatus(w, http.StatusServiceUnavailable, map[string]string{"error": "initial setup is required"})
})
}
func isSetupAllowedPath(path string) bool {
return path == "/setup" ||
path == "/api/setup" ||
path == "/health" ||
path == "/sw.js" ||
strings.HasPrefix(path, "/static/")
}
func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
@@ -87,8 +114,12 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
}
cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil || cookie.Value == "" {
if s.devUserID == "" {
return auth.Principal{}, false
}
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
return principal, err == nil
}
principal, err := s.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
return auth.Principal{}, false
@@ -120,6 +151,8 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/uploads"):
return auth.ScopeDocsWrite, true
case path == "/api/attachments":
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/sync/"):
if method == http.MethodPost {
return auth.ScopeSyncWrite, true

View File

@@ -1,11 +1,12 @@
package httpserver
import (
"context"
"embed"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"strings"
"time"
@@ -50,7 +51,7 @@ type Server struct {
collaboration *collaboration.Service
authLimiter *rateLimiter
templates *template.Template
webEnabled bool
devUserID string
}
func New(deps Dependencies) (http.Handler, error) {
@@ -81,8 +82,13 @@ func New(deps Dependencies) (http.Handler, error) {
templates: templates,
}
if _, err := os.Stat(deps.Config.Web.DistDir); err == nil {
server.webEnabled = true
if deps.Config.DevMode {
devUser, err := deps.Auth.EnsureDevUser(context.Background())
if err != nil {
return nil, fmt.Errorf("ensure dev user: %w", err)
}
server.devUserID = devUser.ID
deps.Logger.Info("dev mode enabled", "dev_user_id", devUser.ID)
}
router := chi.NewRouter()
@@ -92,9 +98,11 @@ func New(deps Dependencies) (http.Handler, error) {
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
router.Use(server.requestLogger)
router.Use(server.securityHeaders)
router.Use(server.setupMiddleware)
router.Use(server.authMiddleware)
router.Get("/", server.handleIndex)
router.Get("/setup", server.handleSetupPage)
router.Get("/login", server.handleLoginPage)
router.Get("/account", server.handleAccountPage)
router.Get("/device/verify", server.handleDeviceVerifyPage)
@@ -102,6 +110,7 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket)
router.Get("/api/auth/me", server.handleAuthMe)
router.Post("/api/setup", server.handleSetup)
router.Post("/api/auth/register/password", server.handlePasswordRegister)
router.Post("/api/auth/login/password", server.handlePasswordLogin)
router.Post("/api/auth/logout", server.handleLogout)
@@ -123,6 +132,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/admin/users", server.handleAdminCreateUser)
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers)
router.Get("/api/admin/settings", server.handleAdminSettings)
router.Patch("/api/admin/settings", server.handleAdminSettingsUpdate)
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
router.Get("/docs", server.handleDocsIndex)
@@ -131,6 +142,7 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/documents/*", server.handleDocumentSave)
router.Get("/api/search", server.handleSearch)
router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList)
router.Get("/attachments/{hash}", server.handleAttachment)
// Collaboration endpoints
@@ -155,18 +167,9 @@ func New(deps Dependencies) (http.Handler, error) {
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
if server.webEnabled {
router.Get("/app", server.handleAppRedirect)
router.Handle("/app/*", http.StripPrefix("/app/", server.appFileServer()))
}
return router, nil
}
func (s *Server) handleAppRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app/", http.StatusMovedPermanently)
}
func mustSub(path string) http.FileSystem {
sub, err := fsSub(path)
if err != nil {

View File

@@ -78,6 +78,22 @@
const formJSON = (form) => Object.fromEntries(new FormData(form).entries());
document.querySelector("[data-initial-setup]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
try {
await postJSON("/api/setup", {
email: form.elements.email.value,
displayName: form.elements.displayName.value,
password: form.elements.password.value,
signupsEnabled: form.elements.signupsEnabled.checked,
});
window.location.href = "/account";
} catch (error) {
setStatus("[data-setup-status]", error.message, true);
}
});
const base64URLToBuffer = (value) => {
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(padded);
@@ -350,6 +366,29 @@
);
};
const loadAdminSettings = async () => {
const section = document.querySelector("[data-admin-settings-section]");
const form = document.querySelector("[data-admin-settings]");
if (!section || !form) return;
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
if (me?.principal?.role !== "admin") return;
section.hidden = false;
const data = await fetch("/api/admin/settings", { credentials: "same-origin" }).then((response) => response.json());
form.elements.signupsEnabled.checked = Boolean(data.settings?.signupsEnabled);
};
document.querySelector("[data-admin-settings]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const form = event.currentTarget;
await postJSON("/api/admin/settings", { signupsEnabled: form.elements.signupsEnabled.checked }, { method: "PATCH" });
setStatus("[data-admin-settings-status]", "Registration setting saved.");
} catch (error) {
setStatus("[data-admin-settings-status]", error.message, true);
}
});
loadTokens().catch(() => {});
loadAdminUsers().catch(() => {});
loadAdminSettings().catch(() => {});
})();

View File

@@ -240,6 +240,16 @@ code {
font-size: 0.9rem;
}
.auth-form .auth-choice {
display: flex;
align-items: center;
gap: 0.55rem;
}
.auth-choice input {
width: auto;
}
.auth-form input,
.auth-form select,
.user-row select {
@@ -1817,4 +1827,3 @@ code {
line-height: 1.5;
}

View File

@@ -56,6 +56,18 @@
<p class="form-status" data-admin-users-status></p>
</section>
<section class="account-section" data-admin-settings-section hidden>
<h2>Registration</h2>
<form class="auth-form" data-admin-settings>
<label class="auth-choice">
<input type="checkbox" name="signupsEnabled" />
Allow visitors to create accounts
</label>
<button type="submit">Save registration setting</button>
<p class="form-status" data-admin-settings-status></p>
</form>
</section>
<section class="account-section account-section--danger">
<h2>Delete Account</h2>
<form class="auth-form" data-account-delete>

View File

@@ -50,6 +50,8 @@
{{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "login_content" }}
{{ template "login_content" .Data }}
{{ else if eq .BodyTemplate "setup_content" }}
{{ template "setup_content" .Data }}
{{ else if eq .BodyTemplate "account_content" }}
{{ template "account_content" .Data }}
{{ else if eq .BodyTemplate "device_verify_content" }}

View File

@@ -43,12 +43,12 @@
</div>
</div>
<div class="auth-register-cta">
{{ if .SignupsEnabled }}<div class="auth-register-cta">
<button type="button" class="btn-cta" data-register-toggle>Create an Account</button>
</div>
</div>{{ end }}
</div>
<div class="auth-register-panel" data-register-panel hidden>
{{ if .SignupsEnabled }}<div class="auth-register-panel" data-register-panel hidden>
<div class="auth-tabs">
<div class="auth-tab-list" role="tablist">
<button type="button" class="auth-tab is-active" role="tab" aria-selected="true" data-tab="passkey-register">Passkey</button>
@@ -93,7 +93,7 @@
<div class="auth-register-cancel">
<button type="button" class="btn-secondary" data-register-cancel>Cancel — Back to Log In</button>
</div>
</div>
</div>{{ end }}
</div>
</section>
{{ end }}

View File

@@ -0,0 +1,34 @@
{{ define "setup.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "setup_content" }}
<section class="auth-shell">
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p>
<h1>Initial setup</h1>
<p class="auth-note">Create the first administrator account and choose whether visitors can register their own accounts.</p>
</div>
<form class="auth-form" data-initial-setup>
<label>
Admin email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Display name
<input type="text" name="displayName" autocomplete="name" />
</label>
<label>
Password
<input type="password" name="password" autocomplete="new-password" minlength="12" required />
</label>
<label class="auth-choice">
<input type="checkbox" name="signupsEnabled" />
Allow visitors to create accounts
</label>
<button type="submit" class="btn-primary">Finish setup</button>
<p class="form-status" data-setup-status></p>
</form>
</div>
</section>
{{ end }}

View File

@@ -1,2 +0,0 @@
dist/

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cairnquire App</title>
<script type="module" src="/src/main.tsx"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -1,25 +0,0 @@
{
"name": "cairnquire-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"format": "prettier --check .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"katex": "^0.16.45",
"lucide-preact": "^1.14.0",
"mermaid": "^11.14.0",
"preact": "^10.27.2",
"preact-iso": "^2.9.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"prettier": "^3.6.2",
"typescript": "^6.0.3",
"vite": "^7.1.12"
}
}

View File

@@ -1,725 +0,0 @@
import {
ChevronRight,
Clock3,
Database,
FileText,
Folder,
FolderGit2,
FolderOpen,
LogIn,
LogOut,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
Shield,
UserPlus,
Users,
} from "lucide-preact";
import { useEffect, useState } from "preact/hooks";
type Role = "admin" | "editor" | "viewer";
type View = "workspace" | "users";
type User = {
id: string;
email: string;
displayName: string;
role: Role;
createdAt: string;
lastSeenAt?: string;
};
type DocumentRecord = {
path: string;
title: string;
currentHash: string;
updatedAt: string;
};
type Workspace = {
sourceDir: string;
storeDir: string;
databasePath: string;
documentCount: number;
userCount: number;
webDistDir: string;
};
type WorkspaceResponse = {
workspace: Workspace;
documents: DocumentRecord[];
};
type Session = {
user: User;
};
type BrowserItem = {
type: "folder" | "document";
name: string;
path: string;
document?: DocumentRecord;
indexDocument?: DocumentRecord;
};
type BrowserColumn = {
title: string;
prefix: string;
items: BrowserItem[];
};
const sessionKey = "mdhub.admin.session";
export function App() {
const [session, setSession] = useState<Session | null>(() => readSession());
const [activeView, setActiveView] = useState<View>("workspace");
const [sidebarOpen, setSidebarOpen] = useState(true);
const [users, setUsers] = useState<User[]>([]);
const [workspace, setWorkspace] = useState<WorkspaceResponse | null>(null);
const [syncing, setSyncing] = useState(false);
const [lastSyncedAt, setLastSyncedAt] = useState("");
const [activePath, setActivePath] = useState("");
const [selectedUserId, setSelectedUserId] = useState("");
async function refresh() {
if (!session) {
return;
}
const [usersResponse, workspaceResponse] = await Promise.all([
api<{ users: User[] }>("/api/admin/users"),
api<WorkspaceResponse>("/api/admin/workspace"),
]);
setUsers(usersResponse.users);
setWorkspace(workspaceResponse);
setLastSyncedAt(new Date().toISOString());
}
useEffect(() => {
void refresh();
}, [session?.user.email]);
useEffect(() => {
const documents = workspace?.documents ?? [];
if (
documents.length > 0 &&
!isKnownDocumentLocation(documents, activePath)
) {
setActivePath(documents[0].path);
}
}, [workspace?.documents, activePath]);
useEffect(() => {
if (users.length > 0 && !users.some((user) => user.id === selectedUserId)) {
setSelectedUserId(users[0].id);
}
}, [users, selectedUserId]);
async function handleLogin(email: string, displayName: string) {
const response = await api<{ user: User }>("/api/admin/login", {
method: "POST",
body: JSON.stringify({ email, displayName, role: "admin" }),
});
const next = { user: response.user };
localStorage.setItem(sessionKey, JSON.stringify(next));
setSession(next);
}
async function handleCreateUser(input: {
email: string;
displayName: string;
role: Role;
}) {
const response = await api<{ user: User }>("/api/admin/users", {
method: "POST",
body: JSON.stringify(input),
});
await refresh();
setSelectedUserId(response.user.id);
}
async function handleSync() {
setSyncing(true);
try {
await api("/api/admin/workspace/sync", { method: "POST" });
await refresh();
} finally {
setSyncing(false);
}
}
function logout() {
localStorage.removeItem(sessionKey);
setSession(null);
setUsers([]);
setWorkspace(null);
setActivePath("");
setSelectedUserId("");
}
if (!session) {
return <LoginScreen onLogin={handleLogin} />;
}
const selectedDocument = selectDocument(
workspace?.documents ?? [],
activePath,
);
const selectedUser =
users.find((user) => user.id === selectedUserId) ?? users[0];
return (
<main class={`admin-shell ${sidebarOpen ? "" : "is-collapsed"}`}>
<aside class="admin-sidebar">
<div class="sidebar-header">
<a class="brand" href="/docs" title="Open public docs">
<Shield size={18} />
<span>MD Hub</span>
</a>
<button
class="icon-button"
type="button"
onClick={() => setSidebarOpen((open) => !open)}
aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
>
{sidebarOpen ? (
<PanelLeftClose size={17} />
) : (
<PanelLeftOpen size={17} />
)}
</button>
</div>
<nav class="nav-list" aria-label="Admin sections">
<button
class={activeView === "workspace" ? "is-active" : ""}
type="button"
onClick={() => setActiveView("workspace")}
title="Workspace"
>
<FolderGit2 size={17} />
<span>Workspace</span>
</button>
<button
class={activeView === "users" ? "is-active" : ""}
type="button"
onClick={() => setActiveView("users")}
title="Users"
>
<Users size={17} />
<span>Users</span>
</button>
</nav>
<button
class="nav-button"
type="button"
onClick={logout}
title="Sign out"
>
<LogOut size={16} />
<span>Sign out</span>
</button>
</aside>
<section class="admin-main">
<header class="compact-topbar">
<div>
<span class="section-label">{activeView}</span>
<strong>
{activeView === "workspace" ? "Documents" : "Accounts"}
</strong>
</div>
<div class="topbar-actions">
{activeView === "workspace" && (
<button
type="button"
class="primary-button sync-button"
onClick={handleSync}
disabled={syncing}
aria-label="Sync workspace"
>
<RefreshCw size={15} />
<span>Sync</span>
</button>
)}
<span class="sync-label">
<Clock3 size={14} />
{syncing
? "Syncing"
: lastSyncedAt
? `Synced ${formatTime(lastSyncedAt)}`
: "Not synced"}
</span>
</div>
</header>
{activeView === "workspace" ? (
<WorkspaceView
data={workspace}
activePath={activePath}
selectedDocument={selectedDocument}
onSelectPath={setActivePath}
/>
) : (
<UsersView
users={users}
selectedUser={selectedUser}
onSelectUser={setSelectedUserId}
onCreateUser={handleCreateUser}
/>
)}
</section>
</main>
);
}
function LoginScreen(props: {
onLogin: (email: string, displayName: string) => Promise<void>;
}) {
const [email, setEmail] = useState("admin@example.com");
const [displayName, setDisplayName] = useState("Admin");
const [error, setError] = useState("");
async function submit(event: Event) {
event.preventDefault();
setError("");
try {
await props.onLogin(email, displayName);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Login failed");
}
}
return (
<main class="login-shell">
<form class="login-panel" onSubmit={submit}>
<div class="login-mark">
<Shield size={20} />
</div>
<h1>Admin login</h1>
<label>
Email
<input
type="email"
value={email}
onInput={(event) => setEmail(event.currentTarget.value)}
required
/>
</label>
<label>
Display name
<input
type="text"
value={displayName}
onInput={(event) => setDisplayName(event.currentTarget.value)}
required
/>
</label>
{error && <p class="form-error">{error}</p>}
<button type="submit" class="primary-button">
<LogIn size={15} />
Sign in
</button>
</form>
</main>
);
}
function WorkspaceView(props: {
data: WorkspaceResponse | null;
activePath: string;
selectedDocument?: DocumentRecord;
onSelectPath: (path: string) => void;
}) {
const documents = props.data?.documents ?? [];
const columns = buildDocumentColumns(documents, props.activePath);
return (
<div class="workspace-grid">
<section class="column-browser" aria-label="Workspace files">
{columns.map((column) => (
<div class="browser-column" key={column.prefix || "root"}>
<header>
<Folder size={15} />
{column.title}
</header>
<div class="item-list">
{column.items.map((item) => (
<button
class={
isActiveItem(item, props.activePath)
? "list-item is-active"
: "list-item"
}
type="button"
key={`${item.type}:${item.path}`}
onClick={() =>
props.onSelectPath(item.indexDocument?.path ?? item.path)
}
>
{item.type === "folder" ? (
isActiveItem(item, props.activePath) ? (
<FolderOpen size={15} />
) : (
<Folder size={15} />
)
) : (
<FileText size={15} />
)}
<span>{item.name}</span>
{item.type === "folder" && <ChevronRight size={14} />}
</button>
))}
{column.items.length === 0 && (
<p class="empty-state">No documents</p>
)}
</div>
</div>
))}
</section>
<WorkspaceDetails
workspace={props.data?.workspace}
document={props.selectedDocument}
/>
</div>
);
}
function WorkspaceDetails(props: {
workspace?: Workspace;
document?: DocumentRecord;
}) {
return (
<aside class="detail-panel">
<header class="detail-header">
<Database size={17} />
<span>Details</span>
</header>
{props.document ? (
<>
<h2>{props.document.title}</h2>
<a class="open-link" href={documentHref(props.document.path)}>
Open document
</a>
<dl class="detail-list">
<div>
<dt>File</dt>
<dd>{props.document.path}</dd>
</div>
<div>
<dt>Hash</dt>
<dd>
<code>{props.document.currentHash.slice(0, 12)}</code>
</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{formatDate(props.document.updatedAt)}</dd>
</div>
</dl>
</>
) : (
<p class="empty-state">Select a document.</p>
)}
{props.workspace && (
<dl class="detail-list compact">
<div>
<dt>Documents</dt>
<dd>{props.workspace.documentCount}</dd>
</div>
<div>
<dt>Content</dt>
<dd>{props.workspace.sourceDir}</dd>
</div>
<div>
<dt>Storage</dt>
<dd>{props.workspace.storeDir}</dd>
</div>
<div>
<dt>Database</dt>
<dd>{props.workspace.databasePath}</dd>
</div>
</dl>
)}
</aside>
);
}
function UsersView(props: {
users: User[];
selectedUser?: User;
onSelectUser: (id: string) => void;
onCreateUser: (input: {
email: string;
displayName: string;
role: Role;
}) => Promise<void>;
}) {
return (
<div class="workspace-grid">
<section class="browser-column user-column" aria-label="Users">
<header>Users</header>
<div class="item-list">
{props.users.map((user) => (
<button
class={
user.id === props.selectedUser?.id
? "list-item is-active"
: "list-item"
}
type="button"
key={user.id}
onClick={() => props.onSelectUser(user.id)}
>
<Users size={15} />
<span>{user.displayName}</span>
<small>{user.role}</small>
</button>
))}
</div>
</section>
<UserDetails
selectedUser={props.selectedUser}
onCreateUser={props.onCreateUser}
/>
</div>
);
}
function UserDetails(props: {
selectedUser?: User;
onCreateUser: (input: {
email: string;
displayName: string;
role: Role;
}) => Promise<void>;
}) {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<Role>("viewer");
async function submit(event: Event) {
event.preventDefault();
await props.onCreateUser({ email, displayName, role });
setEmail("");
setDisplayName("");
setRole("viewer");
}
return (
<aside class="detail-panel">
<header class="detail-header">
<UserPlus size={17} />
<span>Add user</span>
</header>
<form class="stack-form" onSubmit={submit}>
<input
type="email"
placeholder="email@example.com"
value={email}
onInput={(event) => setEmail(event.currentTarget.value)}
required
/>
<input
type="text"
placeholder="Display name"
value={displayName}
onInput={(event) => setDisplayName(event.currentTarget.value)}
required
/>
<select
value={role}
onChange={(event) => setRole(event.currentTarget.value as Role)}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button type="submit" class="primary-button">
<UserPlus size={15} />
Add
</button>
</form>
{props.selectedUser && (
<>
<h2>{props.selectedUser.displayName}</h2>
<dl class="detail-list">
<div>
<dt>Email</dt>
<dd>{props.selectedUser.email}</dd>
</div>
<div>
<dt>Role</dt>
<dd>{props.selectedUser.role}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{formatDate(props.selectedUser.createdAt)}</dd>
</div>
</dl>
</>
)}
</aside>
);
}
function buildDocumentColumns(
documents: DocumentRecord[],
activePath: string,
): BrowserColumn[] {
const activeSegments = activePath.split("/").filter(Boolean);
const prefixes = [""];
const maxFolderDepth = activePath.endsWith(".md")
? activeSegments.length - 1
: activeSegments.length;
for (let index = 0; index < maxFolderDepth; index += 1) {
prefixes.push(activeSegments.slice(0, index + 1).join("/"));
}
return prefixes.map((prefix) => ({
title: prefix ? folderLabel(prefix) : "Documents",
prefix,
items: collectItems(documents, prefix),
}));
}
function collectItems(
documents: DocumentRecord[],
prefix: string,
): BrowserItem[] {
const items = new Map<string, BrowserItem>();
const prefixWithSlash = prefix ? `${prefix}/` : "";
for (const document of documents) {
if (!document.path.startsWith(prefixWithSlash)) {
continue;
}
const remainder = document.path.slice(prefixWithSlash.length);
if (!remainder || remainder === "index.md") {
continue;
}
const [nextSegment, ...rest] = remainder.split("/");
if (rest.length > 0) {
const folderPath = `${prefixWithSlash}${nextSegment}`;
const indexDocument = documents.find(
(candidate) => candidate.path === `${folderPath}/index.md`,
);
items.set(folderPath, {
type: "folder",
name: folderLabel(nextSegment),
path: folderPath,
indexDocument,
});
continue;
}
items.set(document.path, {
type: "document",
name: document.title || fileLabel(document.path),
path: document.path,
document,
});
}
return [...items.values()].sort((left, right) => {
if (left.type !== right.type) {
return left.type === "folder" ? -1 : 1;
}
return left.name.localeCompare(right.name);
});
}
function selectDocument(documents: DocumentRecord[], activePath: string) {
if (!activePath) {
return documents[0];
}
return (
documents.find((document) => document.path === activePath) ??
documents.find((document) => document.path === `${activePath}/index.md`)
);
}
function isKnownDocumentLocation(documents: DocumentRecord[], path: string) {
if (!path) {
return false;
}
return documents.some(
(document) =>
document.path === path ||
document.path === `${path}/index.md` ||
document.path.startsWith(`${path}/`),
);
}
function isActiveItem(item: BrowserItem, activePath: string) {
return (
activePath === item.path ||
activePath === item.indexDocument?.path ||
activePath.startsWith(`${item.path}/`)
);
}
function documentHref(path: string) {
return `/docs/${path.replace(/(^|\/)index\.md$/, "").replace(/\.md$/, "")}`;
}
function folderLabel(path: string) {
return fileLabel(path.split("/").filter(Boolean).at(-1) ?? path);
}
function fileLabel(path: string) {
return path
.replace(/\.md$/, "")
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (match) => match.toUpperCase());
}
async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
return response.json() as Promise<T>;
}
function readSession(): Session | null {
try {
const raw = localStorage.getItem(sessionKey);
return raw ? (JSON.parse(raw) as Session) : null;
} catch {
return null;
}
}
function formatDate(value: string) {
if (!value) {
return "";
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatTime(value: string) {
return new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(new Date(value));
}

View File

@@ -1,11 +0,0 @@
import { render } from "preact";
import { LocationProvider, Route, Router } from "preact-iso";
import { App } from "./app";
import "./styles.css";
render(
<LocationProvider scope="/app">
<Router>{[<Route path="/*" component={App} />]}</Router>
</LocationProvider>,
document.getElementById("app")!,
);

View File

@@ -1,504 +0,0 @@
:root {
color-scheme: light;
--bg: #f4f1ea;
--surface: rgba(255, 255, 255, 0.76);
--surface-strong: #ffffff;
--text: #202227;
--muted: #626775;
--accent: #1947e5;
--accent-soft: rgba(25, 71, 229, 0.1);
--border: rgba(32, 34, 39, 0.12);
--shadow: 0 18px 54px rgba(25, 71, 229, 0.1);
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(
circle at top left,
rgba(25, 71, 229, 0.12),
transparent 34%
),
radial-gradient(
circle at bottom right,
rgba(15, 138, 108, 0.14),
transparent 30%
),
linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%);
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.72;
}
a {
color: var(--accent);
}
code {
font-family: ui-monospace, SFMono-Regular, monospace;
}
.admin-shell {
display: grid;
grid-template-columns: 11rem minmax(0, 1fr);
min-height: 100vh;
transition: grid-template-columns 160ms ease;
}
.admin-shell.is-collapsed {
grid-template-columns: 4rem minmax(0, 1fr);
}
.admin-sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1rem;
padding: 0.8rem;
border-right: 1px solid var(--border);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(14px);
}
.sidebar-header,
.compact-topbar,
.topbar-actions,
.detail-header,
.brand,
.nav-list button,
.nav-button,
.icon-button,
.primary-button,
.sync-label,
.list-item {
display: flex;
align-items: center;
}
.sidebar-header {
justify-content: space-between;
gap: 0.4rem;
}
.brand {
min-width: 0;
gap: 0.5rem;
color: var(--text);
font-weight: 700;
text-decoration: none;
}
.brand span,
.nav-list span,
.nav-button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-button,
.nav-list button,
.nav-button {
border: 0;
color: var(--text);
background: transparent;
}
.icon-button {
justify-content: center;
width: 2rem;
height: 2rem;
flex: 0 0 auto;
border-radius: 0.45rem;
}
.icon-button:hover,
.nav-list button:hover,
.nav-button:hover,
.nav-list button.is-active {
color: var(--accent);
background: var(--accent-soft);
}
.nav-list {
display: grid;
gap: 0.25rem;
}
.nav-list button,
.nav-button {
min-width: 0;
min-height: 2.25rem;
gap: 0.55rem;
padding: 0.45rem 0.55rem;
border-radius: 0.5rem;
text-align: left;
}
.nav-button {
margin-top: auto;
}
.admin-shell.is-collapsed .brand span,
.admin-shell.is-collapsed .nav-list span,
.admin-shell.is-collapsed .nav-button span {
display: none;
}
.admin-shell.is-collapsed .sidebar-header,
.admin-shell.is-collapsed .nav-list button,
.admin-shell.is-collapsed .nav-button {
justify-content: center;
}
.admin-main {
min-width: 0;
padding: 0.85rem;
}
.compact-topbar {
min-height: 3rem;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.8rem;
}
.compact-topbar > div:first-child {
display: grid;
gap: 0.1rem;
}
.section-label {
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.topbar-actions {
gap: 0.55rem;
}
.sync-label {
gap: 0.4rem;
min-height: 2.1rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
background: rgba(255, 255, 255, 0.58);
font-size: 0.92rem;
}
.primary-button {
justify-content: center;
gap: 0.45rem;
min-height: 2.25rem;
padding: 0.45rem 0.75rem;
border: 0;
border-radius: 0.5rem;
color: white;
background: var(--accent);
}
.sync-button {
width: 2.25rem;
padding-inline: 0;
flex: 0 0 auto;
}
.sync-button span {
display: none;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(12rem, 1fr) clamp(11.5rem, 20vw, 15rem);
gap: 0.65rem;
min-height: calc(100vh - 4.8rem);
}
.column-browser {
display: grid;
grid-auto-columns: minmax(11rem, 1fr);
grid-auto-flow: column;
gap: 0.65rem;
min-width: 0;
overflow-x: auto;
}
.browser-column,
.detail-panel,
.login-panel {
min-width: 0;
border: 1px solid var(--border);
border-radius: 0.55rem;
background: var(--surface);
box-shadow: var(--shadow);
}
.browser-column,
.detail-panel {
overflow: hidden;
}
.browser-column > header,
.detail-header {
min-height: 2.5rem;
padding: 0.7rem 0.8rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.detail-header {
gap: 0.45rem;
}
.item-list {
display: grid;
gap: 0.15rem;
padding: 0.45rem;
}
.list-item {
width: 100%;
min-width: 0;
gap: 0.45rem;
min-height: 2.35rem;
padding: 0.45rem 0.5rem;
border: 0;
border-radius: 0.42rem;
color: var(--text);
background: transparent;
text-align: left;
}
.list-item:hover,
.list-item.is-active {
color: var(--accent);
background: var(--accent-soft);
}
.list-item span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-item small {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.72rem;
text-transform: uppercase;
}
.user-column {
min-height: 22rem;
}
.detail-panel {
padding-bottom: 0.9rem;
}
.detail-panel h2 {
margin: 0.85rem 0.85rem 0.35rem;
font-size: 1.45rem;
line-height: 1.05;
}
.open-link {
display: inline-flex;
margin: 0 0.85rem 0.7rem;
font-size: 0.95rem;
}
.detail-list {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0.85rem;
}
.detail-list.compact {
margin-top: 0.35rem;
border-top: 1px solid var(--border);
}
.detail-list div {
min-width: 0;
}
.detail-list dt {
margin-bottom: 0.16rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.detail-list dd {
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stack-form {
display: grid;
gap: 0.5rem;
padding: 0.85rem;
border-bottom: 1px solid var(--border);
}
.stack-form input,
.stack-form select,
.login-panel input {
width: 100%;
min-height: 2.25rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.45rem;
color: var(--text);
background: rgba(255, 255, 255, 0.72);
}
.empty-state {
margin: 0;
padding: 0.8rem;
color: var(--muted);
}
.login-shell {
display: grid;
min-height: 100vh;
place-items: center;
padding: 1rem;
}
.login-panel {
display: grid;
width: min(23rem, 100%);
gap: 0.75rem;
padding: 1rem;
}
.login-panel h1 {
margin: 0;
font-size: 1.6rem;
}
.login-panel label {
display: grid;
gap: 0.3rem;
color: var(--muted);
}
.login-mark {
display: inline-flex;
width: 2.4rem;
height: 2.4rem;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
color: white;
background: var(--accent);
}
.form-error {
margin: 0;
color: #a43131;
}
@media (max-width: 1080px) {
.admin-shell,
.admin-shell.is-collapsed {
grid-template-columns: 1fr;
}
.admin-sidebar {
position: sticky;
top: 0;
z-index: 2;
flex-direction: row;
align-items: center;
}
.nav-list {
display: flex;
}
.nav-button {
margin-top: 0;
}
.admin-shell.is-collapsed .brand span,
.admin-shell.is-collapsed .nav-list span,
.admin-shell.is-collapsed .nav-button span {
display: inline;
}
.workspace-grid {
grid-template-columns: 1fr;
}
.sync-label {
display: none;
}
.column-browser {
min-height: 18rem;
}
}
@media (max-width: 640px) {
.admin-sidebar,
.compact-topbar {
align-items: flex-start;
flex-direction: column;
}
.sidebar-header,
.topbar-actions,
.nav-list {
width: 100%;
}
.nav-list button,
.nav-button,
.primary-button {
width: 100%;
}
.column-browser {
grid-auto-flow: row;
grid-auto-columns: auto;
}
}

View File

@@ -1,6 +0,0 @@
/// <reference types="vite/client" />
declare module "*.css" {
const content: string;
export default content;
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["src"]
}

View File

@@ -1,50 +0,0 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
const serverTarget = "http://localhost:8080";
export default defineConfig({
base: "/app/",
plugins: [preact()],
build: {
outDir: "dist",
sourcemap: true,
},
server: {
port: 5173,
proxy: {
"/api": {
target: serverTarget,
changeOrigin: true,
},
"/attachments": {
target: serverTarget,
changeOrigin: true,
},
"/docs": {
target: serverTarget,
changeOrigin: true,
},
"/health": {
target: serverTarget,
changeOrigin: true,
},
"/static": {
target: serverTarget,
changeOrigin: true,
},
"/sw.js": {
target: serverTarget,
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:8080",
ws: true,
},
"^/app$": {
target: serverTarget,
changeOrigin: true,
},
},
},
});

View File

@@ -10,15 +10,14 @@ services:
CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
CAIRNQUIRE_WEB_DIST_DIR: "/workspace/web-dist"
CAIRNQUIRE_PUBLIC_ORIGIN: "https://cairnquire.bendtstudio.com"
CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}"
CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}"
CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}"
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
volumes:
- ./content:/workspace/content
- ./data:/workspace/data
environment:
CAIRNQUIRE_EMAIL_SMTP_HOST: mailpit
CAIRNQUIRE_EMAIL_SMTP_PORT: "1025"
CAIRNQUIRE_EMAIL_FROM: "notifications@cairnquire.local"
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:8080/health"]
interval: 10s

View File

@@ -1,23 +1,5 @@
[tools]
go = "1.24.2"
node = "24"
pnpm = "10.10.0"
[tasks.web-install]
description = "Install web dependencies"
dir = "apps/web"
run = "pnpm install --frozen-lockfile"
[tasks.web-build]
description = "Build the web app"
depends = ["web-install"]
dir = "apps/web"
run = "pnpm run build"
[tasks.web-dev]
description = "Start the Vite dev server"
dir = "apps/web"
run = "pnpm run dev"
[tasks.server-run]
description = "Run the Go server"
@@ -37,33 +19,12 @@ run = "CGO_ENABLED=1 go test ./..."
[tasks.dev-server]
description = "Run the Go server with air"
dir = "apps/server"
run = "CAIRNQUIRE_DEV_VITE_URL=http://localhost:5173 air"
[tasks.dev-web]
description = "Alias for the Vite dev server"
depends = ["web-dev"]
run = "true"
run = "air"
[tasks.dev]
description = "Run the Go and Vite dev servers"
run = '''
printf 'Starting dev servers...\n'
printf ' - Go server (with air live reload) on http://localhost:8080\n'
printf ' - Vite dev server on http://localhost:5173\n\n'
printf 'Access the app at http://localhost:8080/app/\n\n'
mise run dev-server &
server_pid=$!
mise run dev-web &
web_pid=$!
cleanup() {
kill "$server_pid" "$web_pid" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
wait "$server_pid" "$web_pid"
'''
description = "Start the Go server with live reload"
depends = ["dev-server"]
run = "true"
[tasks.docker-build]
description = "Build the Docker image"
@@ -74,13 +35,11 @@ description = "Run format checks"
run = '''
cd apps/server
go fmt ./...
cd ../web
pnpm run format
'''
[tasks.ci]
description = "Run the core CI checks"
depends = ["web-build", "server-test", "server-build"]
depends = ["server-test", "server-build"]
run = "true"
[tasks.vulncheck]

View File

@@ -1,11 +0,0 @@
{
"name": "cairnquire",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@10.10.0",
"scripts": {
"dev": "pnpm --filter web dev",
"build": "pnpm --filter web build",
"typecheck": "pnpm --filter web typecheck"
}
}

2279
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
packages:
- 'apps/*'
# Delay installs of newly published packages by 7 days.
minimumReleaseAge: 10080