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 data
tmp tmp
**/node_modules
**/dist
**/.vite
coverage coverage
*.log *.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/ data/
tmp/ tmp/
node_modules/
dist/
coverage/ coverage/
.env
.DS_Store .DS_Store
*.log *.log
apps/web/node_modules/
apps/web/dist/
apps/server/bin/ apps/server/bin/

View File

@@ -11,9 +11,9 @@
| Document | Decision | | 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-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-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-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 | | [ADR-006: Email Strategy](adrs/adr-006-email-strategy.md) | Postmark gateway, plain-text only |
@@ -84,8 +84,7 @@ cairnquire/
│ ├── security-specification.md │ ├── security-specification.md
│ └── deployment-guide.md │ └── deployment-guide.md
├── apps/ ├── apps/
── server/ # Go application ── server/ # Go application, templates, and static assets
│ └── web/ # Preact frontend
├── packages/ ├── packages/
│ └── protocol/ # Shared types │ └── protocol/ # Shared types
├── docker-compose.yml ├── 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 ## Status
Accepted Accepted
## Context ## Context
We need to choose a backend language/framework and frontend technology for a documentation platform with these requirements: We need a documentation platform with fast page delivery, minimal external
- Ultra-fast performance dependencies, secure server-side rendering, and a straightforward deployment
- Minimal external dependencies artifact.
- No vendor lock-in
- Battle-tested security
- Server-side rendering for non-interactive pages
- Offline-capable web client
## Decision ## Decision
**Backend**: Go 1.22+ with standard library plus minimal, well-audited packages Use Go 1.24+ with the standard library plus minimal, audited packages. The Go
**Frontend**: Preact 10 with TypeScript, Vite build system 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 ## Consequences
### Positive ### Positive
- Go's standard library is comprehensive and security-audited - One application binary and one build toolchain
- Single static binary deployment — no runtime dependencies - Go `html/template` provides auto-escaped server rendering
- Preact is 10KB (vs 40KB+ React) with identical API - Embedded assets deploy with the server binary
- Go's `html/template` provides safe SSR with auto-escaping - Fewer supply-chain dependencies and fewer moving parts in CI
- TypeScript gives type safety without runtime overhead - Pages remain readable before browser scripts execute
- Both ecosystems have excellent supply-chain security (Go modules with checksums, npm audit)
### Negative ### Negative
- Go template syntax is less expressive than JSX - Template syntax is less expressive than JSX
- WebSocket sync logic must be implemented manually (no framework like Socket.io) - Browser behavior uses small framework-free modules
- Preact ecosystem smaller than React (mitigated by using mostly native APIs) - WebSocket and offline behavior must be maintained directly
## Alternatives Considered ## 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 ### Rust + Axum
- **Pros**: Memory safety guarantees, excellent performance - **Pros**: Memory safety guarantees, excellent performance
- **Cons**: Longer compile times, smaller talent pool, steeper learning curve for team - **Cons**: Longer compile times and steeper learning curve
- **Rejected**: Go's pragmatic balance of safety and velocity better fits project timeline - **Rejected**: Go better fits the project deployment model
### 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
### Next.js / React Server Components ### Next.js / React Server Components
- **Pros**: Mature ecosystem, built-in SSR - **Pros**: Mature ecosystem, built-in SSR
- **Cons**: Heavy bundle size, complex build system, RSC lock-in, many dependencies - **Cons**: Heavy build system and more runtime complexity
- **Rejected**: Violates "minimal dependencies" and "no vendor lock-in" principles - **Rejected**: Violates the minimal dependency goal
## References ## References
- Go Security Policy: https://go.dev/security - 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 ## Status
Accepted Accepted
## Context ## 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 ## 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 ### Implementation Details
1. **Server Rendering**: 1. **Server Rendering**:
- Go handlers query database for document content - Go handlers query document and account data
- Markdown parsed to HTML server-side (Goldmark) - Goldmark parses Markdown to HTML server-side
- `html/template` renders full page with document HTML embedded - `html/template` renders complete pages
- No JavaScript required for reading - Pages are readable before JavaScript executes
2. **Client Hydration**: 2. **Browser Enhancement**:
- Preact components mount into designated DOM containers - Embedded scripts handle editing, comments, authentication, offline cache,
- Comments, search, editor are Preact islands service-worker behavior, and WebSocket updates
- Static content remains server-rendered HTML - Native browser APIs are preferred over a client framework
- `preact-iso` handles client-side navigation between pages - There is no separate SPA route or frontend build pipeline
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
## Consequences ## Consequences
### Positive ### Positive
- First contentful paint is instant (pure HTML) - Fast first contentful paint
- SEO-friendly without extra complexity - Auto-escaped templates
- Graceful degradation: works without JS - One deployable application artifact
- Go templates are auto-escaping XSS-safe - Small browser payloads and no Node build dependency
- No V8/JS runtime embedded in Go binary - Interactive features stay close to their HTTP endpoints
- Smaller client bundle (only interactive components)
### Negative ### Negative
- Some code duplication: markdown parsing logic in both Go and JS (for preview) - Browser modules need direct DOM event handling
- Template syntax is verbose compared to JSX - Shared UI abstractions require deliberate maintenance
- Hydration can cause flicker if not carefully managed - More complex browser interactions need focused integration tests
- Client state must reconcile with server-rendered HTML
## Alternatives Considered ## Alternatives Considered
### Go Templates Only (No Preact) ### Separate Hydrated SPA
- **Pros**: Zero client JS, maximum performance - **Pros**: Component framework and client-side routing
- **Cons**: No interactive features (comments, real-time sync, search) - **Cons**: Duplicates UI paths and introduces a second toolchain
- **Rejected**: Doesn't meet collaboration requirements - **Rejected**: The server-rendered UI already supports the required behavior
### Embedded JS Runtime (QuickJS/V8) ### Embedded JS Runtime
- **Pros**: True isomorphic rendering, JSX templates - **Pros**: Isomorphic rendering
- **Cons**: Adds C dependency, complex build, larger binary, security surface area - **Cons**: Larger binary and additional security surface
- **Rejected**: Violates minimal dependency principle - **Rejected**: Go templates are simpler
### 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
## References ## References
- Go html/template security: https://pkg.go.dev/html/template - Go `html/template`: https://pkg.go.dev/html/template
- Preact hydration: https://preactjs.com/guide/v10/server-side-rendering
- Islands Architecture: https://jasonformat.com/islands-architecture/

View File

@@ -12,7 +12,7 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Browser │ │ File Editor │ │ Mobile / PWA │ │ │ │ Browser │ │ File Editor │ │ Mobile / PWA │ │
│ │ (Preact SPA) │ │ (VS Code, │ │ (Responsive Web) │ │ │ │ (Go HTML UI) │ │ (VS Code, │ │ (Responsive Web) │ │
│ │ │ │ Vim, etc.) │ │ │ │ │ │ │ │ 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 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 ## Data Flow: File Sync

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ Browser users authenticate with passkeys or password fallback. Successful login
Endpoints: Endpoints:
- `GET /setup`
- `POST /api/setup`
- `GET /login` - `GET /login`
- `GET /account` - `GET /account`
- `POST /api/auth/register/password` - `POST /api/auth/register/password`
@@ -21,7 +23,12 @@ Endpoints:
- `POST /api/auth/passkeys/login/begin` - `POST /api/auth/passkeys/login/begin`
- `POST /api/auth/passkeys/login/finish?challengeId=...` - `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 ## API Tokens
@@ -64,7 +71,8 @@ This is intentionally inspired by OAuth device authorization, but it is not a ge
## Route Protection ## 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. - 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. - Admin role required: admin APIs and API token management.

View File

@@ -4,269 +4,120 @@
- Docker 24.0+ - Docker 24.0+
- Docker Compose 2.20+ - Docker Compose 2.20+
- 1GB RAM minimum (2GB recommended) - A reverse proxy that terminates TLS for the public hostname
- 10GB disk space - A writable checkout directory for `./data` and `./content`
- TLS certificate (Let's Encrypt or custom)
## Quick Start ## Fresh Deployment
```bash ```bash
# Clone repository
git clone https://github.com/your-org/cairnquire.git git clone https://github.com/your-org/cairnquire.git
cd cairnquire cd cairnquire
# Copy and edit configuration
cp .env.example .env 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 ```dotenv
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
| 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
``` ```
## Production Checklist Start the application and verify the local container health endpoint:
### 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
```bash ```bash
#!/bin/bash docker compose up -d --build
# backup.sh docker compose ps
curl http://127.0.0.1:8080/health
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
``` ```
**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 ```bash
# Stop application docker compose stop app
docker-compose stop app tar czf cairnquire-backup.tgz data content
docker compose up -d 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
``` ```
## 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 ```bash
docker-compose pull docker compose stop app
docker-compose up -d 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 Database migrations run automatically on startup.
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
## Troubleshooting ## Troubleshooting
### Application Won't Start
```bash ```bash
# Check logs docker compose logs -f app
docker-compose logs -f app docker compose ps
curl http://127.0.0.1:8080/health
# Verify configuration
docker-compose exec app /server -check-config
# Test database
docker-compose exec app sqlite3 /data/db.sqlite ".tables"
``` ```
### Sync Not Working If passkey creation fails after deployment, confirm that the browser URL and
```bash `CAIRNQUIRE_PUBLIC_ORIGIN` are the same HTTPS origin.
# 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

View File

@@ -408,7 +408,6 @@ RUN addgroup -g 1000 appgroup && \
# Read-only root filesystem # Read-only root filesystem
COPY --from=builder /app/server /server COPY --from=builder /app/server /server
COPY --from=builder /app/web/dist /web
# Data directory (writable) # Data directory (writable)
RUN mkdir /data && chown appuser:appgroup /data 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) ### Current State (Already Built)
- Server: Go HTTP + SQLite + file watcher + WebSocket hub - 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 - Documents served at `/:path*` with `index.md` default
- Real-time updates pushed via WebSocket when files change on disk - 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 FROM golang:1.24-bookworm AS server-build
WORKDIR /workspace WORKDIR /workspace
RUN apt-get update && apt-get install -y --no-install-recommends build-essential ca-certificates && rm -rf /var/lib/apt/lists/* 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 RUN useradd --create-home --shell /usr/sbin/nologin appuser
WORKDIR /workspace WORKDIR /workspace
COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire 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 COPY content /workspace/content
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
USER appuser USER appuser
@@ -29,7 +21,5 @@ ENV CAIRNQUIRE_SERVER_ADDR=:8080
ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files 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 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"] 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 - SQLite/libsql-oriented data layer with startup migrations
- Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates - Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates
- Content-addressed attachment storage with secure serving - 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 - Docker, Compose, CI, OpenAPI, and local development targets
## Quick Start ## Quick Start
@@ -30,39 +30,41 @@ mise install
### Development Mode (with live reload) ### 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 ```bash
mise run dev mise run dev
``` ```
This starts: This starts the Go server with `air` live reload on `http://localhost:8080`.
- 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
```
### Production Mode ### Production Mode
Build the web app and run the Go server without Vite proxy: Run the Go server:
```bash ```bash
mise run web-install
mise run web-build
mise run server-run mise run server-run
``` ```
The server listens on `http://localhost:8080` by default. 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 ### Common commands
```bash ```bash
@@ -82,18 +84,20 @@ Configuration can come from:
Supported variables: Supported variables:
- `CAIRNQUIRE_SERVER_ADDR` - `CAIRNQUIRE_PUBLIC_ORIGIN` - Required for deployment. Exact public HTTPS origin used for passkeys and device-flow URLs, for example `https://cairnquire.example.com`.
- `CAIRNQUIRE_DATABASE_PATH` - `CAIRNQUIRE_SERVER_ADDR` - Listen address. Defaults to `:8080`.
- `CAIRNQUIRE_DATABASE_PRIMARY_URL` - `CAIRNQUIRE_DATABASE_PATH` - Local SQLite/libsql path. Defaults to `../../data/db.sqlite` when running from `apps/server`.
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - `CAIRNQUIRE_DATABASE_PRIMARY_URL` - Optional remote libsql primary URL for an embedded replica.
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
- `CAIRNQUIRE_CONTENT_STORE_DIR` - `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
- `CAIRNQUIRE_WEB_DIST_DIR` - `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store.
- `CAIRNQUIRE_DEV_VITE_URL` - Set to proxy `/app/*` to Vite dev server (e.g. `http://localhost:5173`) - `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host. Set to an empty string in JSON config to disable delivery.
- `CAIRNQUIRE_LOG_LEVEL` - `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 ## 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 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. - 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 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) { func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') 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 return value
} }
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func timePtrString(value *time.Time) any { func timePtrString(value *time.Time) any {
if value == nil { if value == nil {
return 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 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) { 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) email = normalizeEmail(email)
if email == "" { if email == "" {
return User{}, fmt.Errorf("email is required") return User{}, fmt.Errorf("email is required")
@@ -72,21 +99,11 @@ func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName,
if err != nil { if err != nil {
return User{}, err 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{ return s.repo.UpsertUser(ctx, User{
Email: email, Email: email,
DisplayName: strings.TrimSpace(displayName), DisplayName: strings.TrimSpace(displayName),
PasswordHash: passwordHash, 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) { 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)) user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil { if err != nil {
if !isNoRows(err) { if !isNoRows(err) {
return nil, "", 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{ user, err = s.repo.UpsertUser(ctx, User{
Email: normalizeEmail(email), Email: normalizeEmail(email),
DisplayName: strings.TrimSpace(displayName), DisplayName: strings.TrimSpace(displayName),
Role: normalizedRole, Role: RoleViewer,
}) })
if err != nil { if err != nil {
return nil, "", err return nil, "", err
@@ -278,6 +290,43 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
return s.repo.ListUsers(ctx) 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) { func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
if !Allows(actor, ScopeAdmin) { if !Allows(actor, ScopeAdmin) {
return User{}, fmt.Errorf("admin role required") 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 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 { func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal {
return Principal{ return Principal{
UserID: user.ID, UserID: user.ID,

View File

@@ -28,13 +28,23 @@ func setupAuthTestService(t *testing.T) *Service {
return 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) { func TestPasswordLoginCreatesValidSession(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() 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 { if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err) t.Fatalf("CompleteInitialSetup() error = %v", err)
} }
if user.Email != "dev@example.com" { if user.Email != "dev@example.com" {
t.Fatalf("email = %q, want normalized", user.Email) t.Fatalf("email = %q, want normalized", user.Email)
@@ -48,7 +58,7 @@ func TestPasswordLoginCreatesValidSession(t *testing.T) {
t.Fatal("expected session token") t.Fatal("expected session token")
} }
if principal.Role != RoleAdmin { 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) validated, err := service.ValidateSessionToken(ctx, token)
@@ -64,10 +74,7 @@ func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") user := setupInitialAdmin(t, service, true)
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
expires := time.Now().UTC().Add(time.Hour) expires := time.Now().UTC().Add(time.Hour)
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires) created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
if err != nil { if err != nil {
@@ -99,10 +106,7 @@ func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") user := setupInitialAdmin(t, service, true)
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead}) start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
if err != nil { if err != nil {
t.Fatalf("StartDeviceFlow() error = %v", err) t.Fatalf("StartDeviceFlow() error = %v", err)
@@ -132,10 +136,7 @@ func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") user := setupInitialAdmin(t, service, true)
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) 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 { if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
t.Fatalf("ChangePassword() error = %v", err) t.Fatalf("ChangePassword() error = %v", err)
@@ -152,10 +153,7 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() ctx := context.Background()
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") user := setupInitialAdmin(t, service, true)
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil { if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
t.Fatal("expected demoting last admin to fail") t.Fatal("expected demoting last admin to fail")
@@ -169,6 +167,7 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() ctx := context.Background()
setupInitialAdmin(t, service, true)
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil { if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err) 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") 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"` Token string `json:"token"`
} }
type InstanceSettings struct {
SetupComplete bool `json:"setupComplete"`
SignupsEnabled bool `json:"signupsEnabled"`
}
type DeviceCode struct { type DeviceCode struct {
ID string ID string
UserID string UserID string

View File

@@ -12,10 +12,10 @@ type Config struct {
Server ServerConfig `json:"server"` Server ServerConfig `json:"server"`
Database DatabaseConfig `json:"database"` Database DatabaseConfig `json:"database"`
Content ContentConfig `json:"content"` Content ContentConfig `json:"content"`
Web WebConfig `json:"web"`
Auth AuthConfig `json:"auth"` Auth AuthConfig `json:"auth"`
Email EmailConfig `json:"email"` Email EmailConfig `json:"email"`
LogLevel string `json:"logLevel"` LogLevel string `json:"logLevel"`
DevMode bool `json:"devMode"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -33,11 +33,6 @@ type ContentConfig struct {
StoreDir string `json:"storeDir"` StoreDir string `json:"storeDir"`
} }
type WebConfig struct {
DistDir string `json:"distDir"`
DevViteURL string `json:"devViteUrl"`
}
type AuthConfig struct { type AuthConfig struct {
PublicOrigin string `json:"publicOrigin"` PublicOrigin string `json:"publicOrigin"`
} }
@@ -60,10 +55,6 @@ func Default() Config {
SourceDir: filepath.Join("..", "..", "content"), SourceDir: filepath.Join("..", "..", "content"),
StoreDir: filepath.Join("..", "..", "data", "files"), StoreDir: filepath.Join("..", "..", "data", "files"),
}, },
Web: WebConfig{
DistDir: filepath.Join("..", "web", "dist"),
DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
},
Auth: AuthConfig{ Auth: AuthConfig{
PublicOrigin: "http://localhost:8080", PublicOrigin: "http://localhost:8080",
}, },
@@ -91,8 +82,6 @@ func Load() (Config, error) {
overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN") overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
overrideString(&cfg.Content.SourceDir, "CAIRNQUIRE_CONTENT_SOURCE_DIR") overrideString(&cfg.Content.SourceDir, "CAIRNQUIRE_CONTENT_SOURCE_DIR")
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_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.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST") overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" { 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.Email.From, "CAIRNQUIRE_EMAIL_FROM")
overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL") 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 == "" { if cfg.Server.Addr == "" {
return Config{}, fmt.Errorf("server.addr must not be empty") 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 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 { type SearchResult struct {
Path string `json:"path"` Path string `json:"path"`
Title string `json:"title"` Title string `json:"title"`

View File

@@ -15,6 +15,10 @@ type adminUserInput struct {
Role string `json:"role"` Role string `json:"role"`
} }
type adminSettingsInput struct {
SignupsEnabled bool `json:"signupsEnabled"`
}
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
var input adminUserInput var input adminUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 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, "databasePath": s.config.Database.Path,
"documentCount": len(documents), "documentCount": len(documents),
"userCount": len(users), "userCount": len(users),
"webDistDir": s.config.Web.DistDir,
}, },
"documents": documents, "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) { func (s *Server) normalizeAdminUserInput(input adminUserInput) (docs.UserRecord, bool) {
email := strings.TrimSpace(strings.ToLower(input.Email)) email := strings.TrimSpace(strings.ToLower(input.Email))
if email == "" { if email == "" {

View File

@@ -32,6 +32,10 @@ type apiTestServer struct {
} }
func newAPITestServer(t *testing.T) *apiTestServer { func newAPITestServer(t *testing.T) *apiTestServer {
return newAPITestServerWithSetup(t, true)
}
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -69,10 +73,13 @@ func newAPITestServer(t *testing.T) *apiTestServer {
if err != nil { if err != nil {
t.Fatalf("create auth service: %v", err) 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 { if err != nil {
t.Fatalf("create test user: %v", err) t.Fatalf("create test user: %v", err)
} }
}
syncRepo := sync.NewRepository(db.SQL()) syncRepo := sync.NewRepository(db.SQL())
handler, err := New(Dependencies{ handler, err := New(Dependencies{
@@ -81,7 +88,6 @@ func newAPITestServer(t *testing.T) *apiTestServer {
SourceDir: sourceDir, SourceDir: sourceDir,
StoreDir: storeDir, StoreDir: storeDir,
}, },
Web: config.WebConfig{DistDir: filepath.Join(root, "web-dist")},
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
}, },
Logger: logger, 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 { func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper() 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"` 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 { type passkeyBeginRequest struct {
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` 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, "//") { if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
nextPath = "/account" 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{ s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
Title: "Sign in", Title: "Sign in",
WebEnabled: s.webEnabled,
BodyClass: "page-auth", BodyClass: "page-auth",
BodyTemplate: "login_content", BodyTemplate: "login_content",
Data: struct { Data: struct {
Next string 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) { func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r) principal, ok := requirePrincipal(r)
if !ok { if !ok {
@@ -86,7 +137,6 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
} }
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{ s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
Title: "Account", Title: "Account",
WebEnabled: s.webEnabled,
BodyClass: "page-account", BodyClass: "page-account",
BodyTemplate: "account_content", BodyTemplate: "account_content",
Data: principal, 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")) userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{ s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
Title: "Approve device", Title: "Approve device",
WebEnabled: s.webEnabled,
BodyClass: "page-auth", BodyClass: "page-auth",
BodyTemplate: "device_verify_content", BodyTemplate: "device_verify_content",
Data: struct { Data: struct {

View File

@@ -23,7 +23,6 @@ import (
type layoutData struct { type layoutData struct {
Title string Title string
WebEnabled bool
BodyClass string BodyClass string
BodyTemplate string BodyTemplate string
Data any Data any
@@ -98,7 +97,6 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/") activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{ s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire", Title: "Cairnquire",
WebEnabled: s.webEnabled,
BodyClass: "page-index", BodyClass: "page-index",
BodyTemplate: "index_content", BodyTemplate: "index_content",
Data: indexData{ 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{ s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query, Title: "Search: " + query,
WebEnabled: s.webEnabled,
BodyClass: "page-search", BodyClass: "page-search",
BodyTemplate: "search_content", BodyTemplate: "search_content",
Data: struct { 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{ s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title, Title: "Edit: " + page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document-edit", BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content", BodyTemplate: "document_edit_content",
Data: documentEditData{ 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{ s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title, Title: page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document", BodyClass: "page-document",
BodyTemplate: "document_content", BodyTemplate: "document_content",
Data: documentData{ Data: documentData{
@@ -403,6 +398,15 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content) _, _ = 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) { func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status) 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) { func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderTemplate(w, status, "error.gohtml", layoutData{ s.renderTemplate(w, status, "error.gohtml", layoutData{
Title: http.StatusText(status), Title: http.StatusText(status),
WebEnabled: s.webEnabled,
BodyClass: "page-error", BodyClass: "page-error",
BodyTemplate: "error_content", BodyTemplate: "error_content",
Data: errorData{ 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) { func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(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) cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
if s.devUserID == "" {
return auth.Principal{}, false 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) principal, err := s.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil { if err != nil {
return auth.Principal{}, false return auth.Principal{}, false
@@ -120,6 +151,8 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeDocsWrite, true return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/uploads"): case strings.HasPrefix(path, "/api/uploads"):
return auth.ScopeDocsWrite, true return auth.ScopeDocsWrite, true
case path == "/api/attachments":
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/sync/"): case strings.HasPrefix(path, "/api/sync/"):
if method == http.MethodPost { if method == http.MethodPost {
return auth.ScopeSyncWrite, true return auth.ScopeSyncWrite, true

View File

@@ -1,11 +1,12 @@
package httpserver package httpserver
import ( import (
"context"
"embed" "embed"
"fmt"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -50,7 +51,7 @@ type Server struct {
collaboration *collaboration.Service collaboration *collaboration.Service
authLimiter *rateLimiter authLimiter *rateLimiter
templates *template.Template templates *template.Template
webEnabled bool devUserID string
} }
func New(deps Dependencies) (http.Handler, error) { func New(deps Dependencies) (http.Handler, error) {
@@ -81,8 +82,13 @@ func New(deps Dependencies) (http.Handler, error) {
templates: templates, templates: templates,
} }
if _, err := os.Stat(deps.Config.Web.DistDir); err == nil { if deps.Config.DevMode {
server.webEnabled = true 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() router := chi.NewRouter()
@@ -92,9 +98,11 @@ func New(deps Dependencies) (http.Handler, error) {
router.Use(server.timeoutExceptWebSocket(30 * time.Second)) router.Use(server.timeoutExceptWebSocket(30 * time.Second))
router.Use(server.requestLogger) router.Use(server.requestLogger)
router.Use(server.securityHeaders) router.Use(server.securityHeaders)
router.Use(server.setupMiddleware)
router.Use(server.authMiddleware) router.Use(server.authMiddleware)
router.Get("/", server.handleIndex) router.Get("/", server.handleIndex)
router.Get("/setup", server.handleSetupPage)
router.Get("/login", server.handleLoginPage) router.Get("/login", server.handleLoginPage)
router.Get("/account", server.handleAccountPage) router.Get("/account", server.handleAccountPage)
router.Get("/device/verify", server.handleDeviceVerifyPage) 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("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket) router.Get("/ws", server.handleWebSocket)
router.Get("/api/auth/me", server.handleAuthMe) 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/register/password", server.handlePasswordRegister)
router.Post("/api/auth/login/password", server.handlePasswordLogin) router.Post("/api/auth/login/password", server.handlePasswordLogin)
router.Post("/api/auth/logout", server.handleLogout) 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.Post("/api/admin/users", server.handleAdminCreateUser)
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole) router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers) 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.Get("/api/admin/workspace", server.handleAdminWorkspace)
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync) router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
router.Get("/docs", server.handleDocsIndex) router.Get("/docs", server.handleDocsIndex)
@@ -131,6 +142,7 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/documents/*", server.handleDocumentSave) router.Post("/api/documents/*", server.handleDocumentSave)
router.Get("/api/search", server.handleSearch) router.Get("/api/search", server.handleSearch)
router.Post("/api/uploads", server.handleUpload) router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList)
router.Get("/attachments/{hash}", server.handleAttachment) router.Get("/attachments/{hash}", server.handleAttachment)
// Collaboration endpoints // Collaboration endpoints
@@ -155,18 +167,9 @@ func New(deps Dependencies) (http.Handler, error) {
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static")))) 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 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 { func mustSub(path string) http.FileSystem {
sub, err := fsSub(path) sub, err := fsSub(path)
if err != nil { if err != nil {

View File

@@ -78,6 +78,22 @@
const formJSON = (form) => Object.fromEntries(new FormData(form).entries()); 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 base64URLToBuffer = (value) => {
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(padded); 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(() => {}); loadTokens().catch(() => {});
loadAdminUsers().catch(() => {}); loadAdminUsers().catch(() => {});
loadAdminSettings().catch(() => {});
})(); })();

View File

@@ -240,6 +240,16 @@ code {
font-size: 0.9rem; 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 input,
.auth-form select, .auth-form select,
.user-row select { .user-row select {
@@ -1817,4 +1827,3 @@ code {
line-height: 1.5; line-height: 1.5;
} }

View File

@@ -56,6 +56,18 @@
<p class="form-status" data-admin-users-status></p> <p class="form-status" data-admin-users-status></p>
</section> </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"> <section class="account-section account-section--danger">
<h2>Delete Account</h2> <h2>Delete Account</h2>
<form class="auth-form" data-account-delete> <form class="auth-form" data-account-delete>

View File

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

View File

@@ -43,12 +43,12 @@
</div> </div>
</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> <button type="button" class="btn-cta" data-register-toggle>Create an Account</button>
</div> </div>{{ end }}
</div> </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-tabs">
<div class="auth-tab-list" role="tablist"> <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> <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"> <div class="auth-register-cancel">
<button type="button" class="btn-secondary" data-register-cancel>Cancel — Back to Log In</button> <button type="button" class="btn-secondary" data-register-cancel>Cancel — Back to Log In</button>
</div> </div>
</div> </div>{{ end }}
</div> </div>
</section> </section>
{{ end }} {{ 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_DATABASE_PATH: "/workspace/data/db.sqlite"
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content" CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files" CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
CAIRNQUIRE_WEB_DIST_DIR: "/workspace/web-dist" CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}"
CAIRNQUIRE_PUBLIC_ORIGIN: "https://cairnquire.bendtstudio.com" 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: volumes:
- ./content:/workspace/content - ./content:/workspace/content
- ./data:/workspace/data - ./data:/workspace/data
environment:
CAIRNQUIRE_EMAIL_SMTP_HOST: mailpit
CAIRNQUIRE_EMAIL_SMTP_PORT: "1025"
CAIRNQUIRE_EMAIL_FROM: "notifications@cairnquire.local"
healthcheck: healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:8080/health"] test: ["CMD", "curl", "--fail", "http://127.0.0.1:8080/health"]
interval: 10s interval: 10s

View File

@@ -1,23 +1,5 @@
[tools] [tools]
go = "1.24.2" 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] [tasks.server-run]
description = "Run the Go server" description = "Run the Go server"
@@ -37,33 +19,12 @@ run = "CGO_ENABLED=1 go test ./..."
[tasks.dev-server] [tasks.dev-server]
description = "Run the Go server with air" description = "Run the Go server with air"
dir = "apps/server" dir = "apps/server"
run = "CAIRNQUIRE_DEV_VITE_URL=http://localhost:5173 air" run = "air"
[tasks.dev-web]
description = "Alias for the Vite dev server"
depends = ["web-dev"]
run = "true"
[tasks.dev] [tasks.dev]
description = "Run the Go and Vite dev servers" description = "Start the Go server with live reload"
run = ''' depends = ["dev-server"]
printf 'Starting dev servers...\n' run = "true"
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"
'''
[tasks.docker-build] [tasks.docker-build]
description = "Build the Docker image" description = "Build the Docker image"
@@ -74,13 +35,11 @@ description = "Run format checks"
run = ''' run = '''
cd apps/server cd apps/server
go fmt ./... go fmt ./...
cd ../web
pnpm run format
''' '''
[tasks.ci] [tasks.ci]
description = "Run the core CI checks" description = "Run the core CI checks"
depends = ["web-build", "server-test", "server-build"] depends = ["server-test", "server-build"]
run = "true" run = "true"
[tasks.vulncheck] [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