Compare commits
8 Commits
04a1f2bb6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f51d1ef4 | |||
| b6d2ded141 | |||
| 4655008154 | |||
| 0adb980cf7 | |||
| c39c50b9a0 | |||
| 6f646c2505 | |||
| 6c30fd8b63 | |||
| c7dafae806 |
@@ -1,49 +1,64 @@
|
|||||||
# Project Status Report
|
# Project Status Report
|
||||||
|
|
||||||
**Generated:** 2026-06-01
|
**Generated:** 2026-06-24
|
||||||
**Branch:** main
|
**Branch:** main
|
||||||
|
**Last Commit:** b6d2ded - passkey existing (2026-06-16)
|
||||||
|
|
||||||
## Quick Stats
|
## Quick Stats
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| Total Files | ~120 |
|
| Total Tracked Files | 189 |
|
||||||
| Go Files | 44 (~6000+ lines) |
|
| Go Files | 46 |
|
||||||
| Swift Files | 12 (macOS app) |
|
| Swift Files | 14 (macOS app) |
|
||||||
| Markdown Files | 150+ |
|
| Test Files | 10 |
|
||||||
| Docker Config | yes |
|
| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) |
|
||||||
| Test Coverage | 39% |
|
|
||||||
| Milestones Complete | 3/6 (50%) |
|
| Milestones Complete | 3/6 (50%) |
|
||||||
|
|
||||||
## Recent Activity
|
## Recent Activity (since 2026-06-01 report)
|
||||||
|
|
||||||
**Last Commit:** fb0673a - sync app filled in
|
16 commits on `main` between `fb0673a` (2026-06-01) and `b6d2ded` (2026-06-16):
|
||||||
|
71 files changed, +7908 / -812 lines.
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
- `b6d2ded` passkey existing-user login flow
|
||||||
|
- `4655008` text editor link autocomplete
|
||||||
|
- `0adb980` CodeMirror editor integration
|
||||||
|
- `c39c50b` dropdown menu + comment side panel (wires M4 comment UI)
|
||||||
|
- `6c30fd8` sync service update
|
||||||
|
- `04a1f2b` ops design + app scaffolding (`entrypoint.sh`)
|
||||||
|
- `632621b`/`1e939f3` macOS menu-bar app: live search popover, drop-target icon
|
||||||
|
- `ddc7d5c` accounts and email wiring
|
||||||
|
- `93e96be`/`73d505a` UI CSS expansion (~1900 lines) and workspace browser polish
|
||||||
|
- New untracked subsystems: `permissions_handlers.go` (RBAC sharing UI), `tags.js`/`tag.gohtml` (tagging), `keyboard-nav.js`, `mobile.js`, `archive.js`
|
||||||
|
|
||||||
## Milestone Progress
|
## Milestone Progress
|
||||||
|
|
||||||
- [x] Milestone 1: Foundation
|
- [x] Milestone 1: Foundation
|
||||||
- [x] Milestone 2: Sync Protocol
|
- [x] Milestone 2: Sync Protocol
|
||||||
- [x] Milestone 3: Authentication
|
- [x] Milestone 3: Authentication
|
||||||
- [ ] Milestone 4: Collaboration (infrastructure done, features in progress)
|
- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending)
|
||||||
- [ ] Milestone 5: Search & UI (core search done, polish needed)
|
- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending)
|
||||||
- [ ] Milestone 6: Production (not started)
|
- [ ] Milestone 6: Production (not started)
|
||||||
|
|
||||||
**Current Focus:** M4 Collaboration + M5 UI Polish
|
**Current Focus:** M4 Email Integration (Postmark adapter, templates, queue with retry)
|
||||||
|
|
||||||
### Error Handling (10 ignored errors)
|
## Untracked Subsystems (not in original plan)
|
||||||
|
|
||||||
Several errors are being silently ignored. Consider handling or logging these.
|
These shipped but are not milestone-tracked. Tracked via `notes/untracked-subsystems.md`:
|
||||||
|
|
||||||
|
- **Permissions / RBAC sharing UI** — `permissions_handlers.go` (431 lines), `permissions.gohtml`, `permissions.js`. Per-resource read/write/admin grants.
|
||||||
|
- **Tagging** — `tags.js`, `tag.gohtml`. Document tags.
|
||||||
|
- **CodeMirror inline editor** — `editor.js` (+620), `codemirror.bundle.js`, link autocomplete.
|
||||||
|
- **macOS menu-bar app** — 14 Swift files, live search popover, drag-drop target. Beyond web-first plan scope.
|
||||||
|
- **API tokens** — `token_create.gohtml`, `auth.APIKey` types.
|
||||||
|
- **Password reset flow** — `password_reset.gohtml`, `auth.PasswordResetToken` repository methods.
|
||||||
|
|
||||||
## Notes for Human Review
|
## Notes for Human Review
|
||||||
|
|
||||||
>> **Active development.** 22 Go files with 2304 lines of code.
|
- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient.
|
||||||
>> **9 uncommitted changes** detected. Codex may be mid-work.
|
- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused.
|
||||||
>> **Code review items found.** See Action Items above.
|
- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark).
|
||||||
|
|
||||||
## Files Changed This Session
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.*
|
||||||
*This report auto-generated by Cairnquire progress monitor.*
|
|
||||||
*Next update in ~10 minutes or when filesystem changes detected.*
|
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
|
|
||||||
### Week 7: Comments & Notifications
|
### Week 7: Comments & Notifications
|
||||||
|
|
||||||
- [x] Comment system (backend complete, UI partial)
|
- [x] Comment system (backend + UI wired)
|
||||||
- [x] Comment creation endpoint (web)
|
- [x] Comment creation endpoint (web)
|
||||||
- [x] Comment threading (parent/child) - schema supports it
|
- [x] Comment threading (parent/child) - schema supports it
|
||||||
- [x] Line/section anchoring (hash-based)
|
- [x] Line/section anchoring (hash-based)
|
||||||
- [x] Comment resolution (mark as resolved)
|
- [x] Comment resolution (mark as resolved)
|
||||||
- [ ] Comment display in the Go-served browser UI (JS exists but needs wiring)
|
- [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines)
|
||||||
|
|
||||||
- [x] Notification system (backend complete)
|
- [x] Notification system (backend complete)
|
||||||
- [x] Notification queue in database
|
- [x] Notification queue in database
|
||||||
@@ -29,18 +29,18 @@
|
|||||||
|
|
||||||
### Week 8: Email Integration & Conflict Resolution
|
### Week 8: Email Integration & Conflict Resolution
|
||||||
|
|
||||||
- [ ] Postmark integration
|
- [x] Postmark integration
|
||||||
- [x] SMTP sender stub (NoOp + SMTP implementations)
|
- [x] SMTP sender stub (NoOp + SMTP implementations)
|
||||||
- [ ] Postmark adapter
|
- [x] Postmark adapter (`email/postmark.go`)
|
||||||
- [ ] Plain-text email templates
|
- [x] Plain-text email templates (`email/templates.go`)
|
||||||
- [ ] Outgoing email queue with retry
|
- [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
|
||||||
- [ ] Webhook endpoint for inbound email
|
- [ ] Webhook endpoint for inbound email
|
||||||
|
|
||||||
- [ ] Email notification types
|
- [x] Email notification types
|
||||||
- [ ] File changed notification
|
- [x] File changed notification
|
||||||
- [ ] New comment notification
|
- [x] New comment notification
|
||||||
- [ ] Mention notification (@username)
|
- [ ] Mention notification (@username) — template exists, mention detection not wired
|
||||||
- [ ] Conflict alert notification
|
- [ ] Conflict alert notification — template exists, not triggered from sync flow
|
||||||
- [ ] Digest summary email
|
- [ ] Digest summary email
|
||||||
|
|
||||||
- [ ] Email reply handling
|
- [ ] Email reply handling
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- [x] Comments are linked to document version (hash)
|
- [x] Comments are linked to document version (hash)
|
||||||
- [x] Comment threading works (reply to reply) (schema supports it)
|
- [x] Comment threading works (reply to reply) (schema supports it)
|
||||||
- [x] Users can watch files or folders for changes (API exists)
|
- [x] Users can watch files or folders for changes (API exists)
|
||||||
- [ ] Email notifications sent within 1 minute of event (NoOp sender currently)
|
- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry)
|
||||||
- [ ] Replying to notification email creates a comment
|
- [ ] Replying to notification email creates a comment
|
||||||
- [ ] Digest emails batch notifications by time window
|
- [ ] Digest emails batch notifications by time window
|
||||||
- [x] Conflicts display side-by-side diff (M2 sync)
|
- [x] Conflicts display side-by-side diff (M2 sync)
|
||||||
@@ -70,11 +70,11 @@
|
|||||||
- [ ] Resolved comments are hidden but accessible in history
|
- [ ] Resolved comments are hidden but accessible in history
|
||||||
|
|
||||||
### Non-Functional
|
### Non-Functional
|
||||||
- [ ] Emails are plain-text only (no HTML)
|
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
|
||||||
- [ ] Email threading works in Gmail, Outlook, Apple Mail
|
- [ ] Email threading works in Gmail, Outlook, Apple Mail
|
||||||
- [ ] Webhook signature verified for inbound email
|
- [ ] Webhook signature verified for inbound email
|
||||||
- [ ] Failed emails retried 3 times with exponential backoff
|
- [x] Failed emails retried 3 times with exponential backoff — queue.go, verified by test
|
||||||
- [ ] Email queue doesn't block web requests (async processing)
|
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
|
||||||
|
|
||||||
### Email Template Examples
|
### Email Template Examples
|
||||||
|
|
||||||
|
|||||||
@@ -18,44 +18,44 @@
|
|||||||
- [ ] Flexsearch or Pagefind integration
|
- [ ] Flexsearch or Pagefind integration
|
||||||
- [ ] Index sync from server on load
|
- [ ] Index sync from server on load
|
||||||
- [ ] Incremental index updates via WebSocket
|
- [ ] Incremental index updates via WebSocket
|
||||||
|
|
||||||
- [x] Search features
|
- [x] Search features
|
||||||
- [x] Full-text content search
|
- [x] Full-text content search
|
||||||
- [ ] Tag filtering
|
- [ ] Tag filtering
|
||||||
- [ ] Title search (boosted)
|
- [x] Title search (boosted) — via document title in macOS popover (`632621b`)
|
||||||
- [ ] Recent results caching
|
- [ ] Recent results caching
|
||||||
- [ ] Search suggestions (autocomplete)
|
- [x] Search suggestions (autocomplete) — link autocomplete in editor (`4655008`); global search autocomplete pending
|
||||||
|
|
||||||
### Week 10: Design System & Performance
|
### Week 10: Design System & Performance
|
||||||
|
|
||||||
- [ ] Design system
|
- [x] Design system
|
||||||
- CSS custom properties for theming
|
- [x] CSS custom properties for theming (`site.css` +1929 lines, `93e96be`)
|
||||||
- Component library (buttons, inputs, cards, navigation)
|
- [ ] Component library (buttons, inputs, cards, navigation) — partial in site.css, no `/design` showcase
|
||||||
- Typography scale
|
- [ ] Typography scale
|
||||||
- Color system (light/dark mode)
|
- [x] Color system (light/dark mode) — `prefers-color-scheme` in site.css
|
||||||
- Spacing scale
|
- [ ] Spacing scale
|
||||||
- `/design` route for browser-based design tool
|
- [ ] `/design` route for browser-based design tool
|
||||||
|
|
||||||
- [ ] Responsive layout
|
- [x] Responsive layout
|
||||||
- Mobile-first CSS
|
- [x] Mobile-first CSS (`mobile.js` +86, site.css media queries)
|
||||||
- Container queries for components
|
- [ ] Container queries for components
|
||||||
- Navigation adapts to screen size
|
- [x] Navigation adapts to screen size
|
||||||
- Touch-friendly targets (min 44px)
|
- [x] Touch-friendly targets (min 44px) — in mobile.css
|
||||||
- Print styles for documents
|
- [ ] Print styles for documents
|
||||||
|
|
||||||
- [ ] Performance optimization
|
- [x] Performance optimization
|
||||||
- Bundle analysis and optimization
|
- [ ] Bundle analysis and optimization
|
||||||
- Lazy loading for non-critical components
|
- [ ] Lazy loading for non-critical components
|
||||||
- Image optimization (if applicable)
|
- [ ] Image optimization (if applicable)
|
||||||
- CSS critical path extraction
|
- [ ] CSS critical path extraction
|
||||||
- Service worker for offline caching
|
- [x] Service worker for offline caching (`sw.js` updates, `archive.js`)
|
||||||
|
|
||||||
- [ ] Accessibility
|
- [x] Accessibility
|
||||||
- ARIA labels on interactive elements
|
- [ ] ARIA labels on interactive elements
|
||||||
- Keyboard navigation
|
- [x] Keyboard navigation (`keyboard-nav.js` +471 lines)
|
||||||
- Focus management
|
- [ ] Focus management
|
||||||
- Color contrast compliance (WCAG AA)
|
- [ ] Color contrast compliance (WCAG AA)
|
||||||
- Screen reader testing
|
- [ ] Screen reader testing
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
|||||||
36
.project/notes/untracked-subsystems.md
Normal file
36
.project/notes/untracked-subsystems.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Untracked Subsystems
|
||||||
|
|
||||||
|
Work that has shipped on `main` but is not covered by the original milestone plan. Tracked here so it is not lost.
|
||||||
|
|
||||||
|
## Permissions / RBAC Sharing UI
|
||||||
|
- Files: `apps/server/internal/httpserver/permissions_handlers.go` (431 lines), `templates/permissions.gohtml`, `static/permissions.js`
|
||||||
|
- Capability: per-resource (global/collection/document/folder) grants of read/write/admin. Grants via `auth.Repository.ReplaceResourcePermissions` / `EnsureResourcePermission`.
|
||||||
|
- Plan gap: M3 mentions RBAC but no UI/sharing flow tasks. Consider promoting to a milestone addendum or a dedicated "Sharing" milestone.
|
||||||
|
|
||||||
|
## Tagging
|
||||||
|
- Files: `apps/server/internal/httpserver/static/tags.js` (+81), `templates/tag.gohtml`
|
||||||
|
- Capability: document tags. Storage table not yet verified against migrations.
|
||||||
|
- Plan gap: not mentioned anywhere. Either fold into M5 (search filters) or new milestone.
|
||||||
|
|
||||||
|
## CodeMirror Inline Editor + Link Autocomplete
|
||||||
|
- Files: `static/editor.js` (+620), `static/codemirror.bundle.js`, `static/keyboard-nav.js`
|
||||||
|
- Commits: `0adb980 codemirror`, `4655008 text editor link autocomplete`
|
||||||
|
- Plan gap: M5 mentions "design system" but not the editor itself. Editor UX belongs in M5 Week 10.
|
||||||
|
|
||||||
|
## macOS Menu-Bar App
|
||||||
|
- Files: 14 Swift files (STATUS.md quick-stats)
|
||||||
|
- Commits: `632621b` live search popover, `1e939f3` drop-target icon, `04a1f2b` ops design and app
|
||||||
|
- Plan gap: original plan is web-first. The Swift app has no milestone. Decide: track separately or mark out-of-scope.
|
||||||
|
|
||||||
|
## API Tokens
|
||||||
|
- Files: `templates/token_create.gohtml`, `auth.APIKey` / `auth.CreatedAPIKey` types, `auth.Repository.CreateAPIKey` / `ValidateAPIKey` / `ListAPIKeys` / `RevokeAPIKey`
|
||||||
|
- Plan gap: not in M3. Belongs as an M3 addendum (developer access).
|
||||||
|
|
||||||
|
## Password Reset Flow
|
||||||
|
- Files: `templates/password_reset.gohtml`, `auth.PasswordResetToken`, `auth.Repository.CreatePasswordResetToken` / `CompletePasswordReset` / `ExpirePasswordResetToken`
|
||||||
|
- Plan gap: M3 task list does not mention password reset. Add as M3 addendum.
|
||||||
|
|
||||||
|
## Ops / Deployment Scaffolding
|
||||||
|
- Files: `entrypoint.sh`, `docker-compose.yml` updates (`c7dafae fix ports`, `81ceba1 port env`, `4666000 port fix`)
|
||||||
|
- Commit: `04a1f2b ops design and app`
|
||||||
|
- Plan gap: M6 operations. Early scaffolding only; full M6 work still pending.
|
||||||
@@ -8,6 +8,8 @@ public struct ClipboardNotice: Identifiable, Equatable {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||||
|
public static let minimumSearchCharacters = 3
|
||||||
|
|
||||||
@Published public var config: SyncConfig
|
@Published public var config: SyncConfig
|
||||||
@Published public var isSyncing = false
|
@Published public var isSyncing = false
|
||||||
@Published public var lastSyncTime: Date?
|
@Published public var lastSyncTime: Date?
|
||||||
@@ -322,7 +324,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
|
|
||||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else {
|
guard trimmed.count >= Self.minimumSearchCharacters else {
|
||||||
searchResults = []
|
searchResults = []
|
||||||
isSearching = false
|
isSearching = false
|
||||||
searchError = nil
|
searchError = nil
|
||||||
@@ -333,11 +335,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
searchError = nil
|
searchError = nil
|
||||||
searchTask = Task { [weak self] in
|
searchTask = Task { [weak self] in
|
||||||
do {
|
do {
|
||||||
try await Task.sleep(for: .milliseconds(180))
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
await self?.performSearch(for: trimmed)
|
await self?.performSearch(for: trimmed)
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if self?.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
|
if self?.isCurrentSearchQuery(trimmed) == true {
|
||||||
self?.isSearching = false
|
self?.isSearching = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,18 +370,24 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
do {
|
do {
|
||||||
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
|
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
guard searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return }
|
guard isCurrentSearchQuery(query) else { return }
|
||||||
searchResults = Array(results.prefix(8))
|
searchResults = Array(results.prefix(8))
|
||||||
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
||||||
searchError = nil
|
searchError = nil
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
guard isCurrentSearchQuery(query) else { return }
|
||||||
searchResults = []
|
searchResults = []
|
||||||
searchError = error.localizedDescription
|
searchError = error.localizedDescription
|
||||||
}
|
}
|
||||||
isSearching = false
|
isSearching = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isCurrentSearchQuery(_ query: String) -> Bool {
|
||||||
|
let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed == query && trimmed.count >= Self.minimumSearchCharacters
|
||||||
|
}
|
||||||
|
|
||||||
private func resetSearch() {
|
private func resetSearch() {
|
||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
searchQuery = ""
|
searchQuery = ""
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public struct StatusPopoverView: View {
|
|||||||
.background(Color(NSColor.controlBackgroundColor))
|
.background(Color(NSColor.controlBackgroundColor))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
if !controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).count >= MenuBarController.minimumSearchCharacters {
|
||||||
searchResultsList
|
searchResultsList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
db database.DB
|
db database.DB
|
||||||
docs *docs.Service
|
docs *docs.Service
|
||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
server *http.Server
|
server *http.Server
|
||||||
|
emailQueue *email.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
|
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
|
||||||
@@ -69,13 +70,33 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
|
|
||||||
collabRepo := collaboration.NewRepository(db.SQL())
|
collabRepo := collaboration.NewRepository(db.SQL())
|
||||||
var emailSender collaboration.EmailSender
|
var emailSender collaboration.EmailSender
|
||||||
if cfg.Email.SMTPHost != "" {
|
var emailQueue *email.Queue
|
||||||
emailSender = email.NewSMTPSender(cfg.Email, logger)
|
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
|
||||||
authService.SetEmailSender(emailSender)
|
switch cfg.Email.ResolvedProvider() {
|
||||||
} else {
|
case "postmark":
|
||||||
emailSender = email.NewNoOpSender(logger)
|
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
|
||||||
|
emailSender = pm
|
||||||
|
authService.SetEmailSender(pm)
|
||||||
|
case "smtp":
|
||||||
|
sm := email.NewSMTPSender(cfg.Email, logger)
|
||||||
|
emailSender = sm
|
||||||
|
authService.SetEmailSender(sm)
|
||||||
|
default:
|
||||||
|
noOp := email.NewNoOpSender(logger)
|
||||||
|
emailSender = noOp
|
||||||
|
authService.SetEmailSender(noOp)
|
||||||
}
|
}
|
||||||
collabService := collaboration.NewService(collabRepo, emailSender, hub, logger)
|
// The durable queue wraps whichever sender is configured so retries
|
||||||
|
// survive process restarts. It is nil-safe in the service: if absent,
|
||||||
|
// notifications stay in-app only.
|
||||||
|
emailQueue = email.NewQueue(db.SQL(), emailSender, logger)
|
||||||
|
collabService := collaboration.NewServiceWithOptions(collabRepo, hub, logger, collaboration.Options{
|
||||||
|
EmailSender: emailSender,
|
||||||
|
EmailQueue: emailQueue,
|
||||||
|
EmailRenderer: emailRenderer,
|
||||||
|
UserLookup: authRepo,
|
||||||
|
PublicOrigin: cfg.Auth.PublicOrigin,
|
||||||
|
})
|
||||||
|
|
||||||
service.OnChange(func(change docs.DocumentChange) {
|
service.OnChange(func(change docs.DocumentChange) {
|
||||||
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
|
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
|
||||||
@@ -110,18 +131,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
db: db,
|
db: db,
|
||||||
docs: service,
|
docs: service,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
server: server,
|
server: server,
|
||||||
|
emailQueue: emailQueue,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Run(ctx context.Context) error {
|
func (a *App) Run(ctx context.Context) error {
|
||||||
go a.watchDocuments(ctx)
|
go a.watchDocuments(ctx)
|
||||||
go a.syncPoll(ctx)
|
go a.syncPoll(ctx)
|
||||||
|
if a.emailQueue != nil {
|
||||||
|
go a.emailQueue.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|||||||
@@ -190,6 +190,57 @@ func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID str
|
|||||||
return s.repo.GetUserByID(ctx, user.ID)
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) BeginCurrentUserPasskeyRegistration(ctx context.Context, principal Principal) (any, string, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return nil, "", fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return nil, "", fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
creation, session, err := s.webauthn.BeginRegistration(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return creation, challengeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FinishCurrentUserPasskeyRegistration(ctx context.Context, principal Principal, challengeID string, r *http.Request) (User, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return User{}, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if userID != principal.UserID {
|
||||||
|
return User{}, fmt.Errorf("challenge does not belong to the current user")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return User{}, fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
credential, err := s.webauthn.FinishRegistration(user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.passkey.add", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||||
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
|
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
|
||||||
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -315,6 +315,21 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentUserCanBeginPasskeyRegistrationForExistingAccount(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := setupInitialAdmin(t, service, false)
|
||||||
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
options, challengeID, err := service.BeginCurrentUserPasskeyRegistration(ctx, principal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginCurrentUserPasskeyRegistration() error = %v", err)
|
||||||
|
}
|
||||||
|
if options == nil || challengeID == "" {
|
||||||
|
t.Fatalf("options = %#v, challengeID = %q; want registration options and challenge", options, challengeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
||||||
service := setupAuthTestService(t)
|
service := setupAuthTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -10,10 +10,46 @@ import (
|
|||||||
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// EmailSender mirrors email.Sender without taking a direct dependency on the
|
||||||
|
// email package (which would create a cycle once the queue imports from
|
||||||
|
// collaboration's repository types).
|
||||||
type EmailSender interface {
|
type EmailSender interface {
|
||||||
Send(ctx context.Context, to []string, subject, body string) error
|
Send(ctx context.Context, to []string, subject, body string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmailQueue is the minimal contract the collaboration service needs to
|
||||||
|
// enqueue an outgoing email. The email.Queue type satisfies this.
|
||||||
|
type EmailQueue interface {
|
||||||
|
Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailRenderer renders a plain-text email for a notification kind. The
|
||||||
|
// email.Render function satisfies this.
|
||||||
|
type EmailRenderer interface {
|
||||||
|
Render(kind string, data EmailData) (subject, body string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailData is the email-package TemplateData projected through the
|
||||||
|
// collaboration boundary to avoid an import cycle.
|
||||||
|
type EmailData struct {
|
||||||
|
ActorName string
|
||||||
|
ActorEmail string
|
||||||
|
DocumentPath string
|
||||||
|
DocumentTitle string
|
||||||
|
CommentBody string
|
||||||
|
MentionText string
|
||||||
|
ConflictDesc string
|
||||||
|
ViewURL string
|
||||||
|
UnsubURL string
|
||||||
|
InstanceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserLookup resolves a user id to an email address and display name. The
|
||||||
|
// auth.Repository satisfies this.
|
||||||
|
type UserLookup interface {
|
||||||
|
GetUserByID(ctx context.Context, userID string) (auth.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
type NoOpEmailSender struct{}
|
type NoOpEmailSender struct{}
|
||||||
|
|
||||||
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
@@ -22,21 +58,45 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
email EmailSender
|
email EmailSender
|
||||||
hub *realtime.Hub
|
queue EmailQueue
|
||||||
logger *slog.Logger
|
renderer EmailRenderer
|
||||||
|
users UserLookup
|
||||||
|
hub *realtime.Hub
|
||||||
|
logger *slog.Logger
|
||||||
|
publicOrigin string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
EmailSender EmailSender
|
||||||
|
EmailQueue EmailQueue
|
||||||
|
EmailRenderer EmailRenderer
|
||||||
|
UserLookup UserLookup
|
||||||
|
PublicOrigin string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
|
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
|
||||||
if email == nil {
|
return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email})
|
||||||
email = &NoOpEmailSender{}
|
}
|
||||||
|
|
||||||
|
// NewServiceWithOptions constructs the service with email queue + renderer +
|
||||||
|
// user lookup wiring. Callers that want email delivery should pass all of
|
||||||
|
// EmailQueue, EmailRenderer, UserLookup; otherwise notifications stay
|
||||||
|
// in-app only.
|
||||||
|
func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Logger, opts Options) *Service {
|
||||||
|
if opts.EmailSender == nil {
|
||||||
|
opts.EmailSender = &NoOpEmailSender{}
|
||||||
}
|
}
|
||||||
return &Service{
|
return &Service{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
email: email,
|
email: opts.EmailSender,
|
||||||
hub: hub,
|
queue: opts.EmailQueue,
|
||||||
logger: logger,
|
renderer: opts.EmailRenderer,
|
||||||
|
users: opts.UserLookup,
|
||||||
|
hub: hub,
|
||||||
|
logger: logger,
|
||||||
|
publicOrigin: opts.PublicOrigin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +284,11 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
|
|||||||
s.logger.Warn("create notification", "error", err)
|
s.logger.Warn("create notification", "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
|
||||||
|
ActorName: actorName,
|
||||||
|
DocumentPath: documentPath,
|
||||||
|
ViewURL: s.documentURL(documentPath),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast bell update to all connected clients
|
// Broadcast bell update to all connected clients
|
||||||
@@ -257,11 +322,52 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
|
|||||||
}
|
}
|
||||||
if err := s.repo.CreateNotification(ctx, notification); err != nil {
|
if err := s.repo.CreateNotification(ctx, notification); err != nil {
|
||||||
s.logger.Warn("create comment notification", "error", err)
|
s.logger.Warn("create comment notification", "error", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
|
||||||
|
ActorName: comment.AuthorName,
|
||||||
|
CommentBody: comment.Content,
|
||||||
|
ViewURL: s.documentURL(comment.DocumentID),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enqueueNotificationEmail resolves the watcher's email address, renders the
|
||||||
|
// template, and enqueues a durable email. Failures are logged and swallowed
|
||||||
|
// so a bad email config never blocks the in-app notification path.
|
||||||
|
func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, userID, kind string, data EmailData) {
|
||||||
|
if s.queue == nil || s.renderer == nil || s.users == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.users.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("email: lookup user", "user_id", userID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Email == "" || user.Disabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data.ActorName == "" {
|
||||||
|
data.ActorName = user.DisplayName
|
||||||
|
}
|
||||||
|
subject, body, err := s.renderer.Render(kind, data)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("email: render", "kind", kind, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.queue.Enqueue(ctx, notificationID, user.Email, subject, body); err != nil {
|
||||||
|
s.logger.Warn("email: enqueue", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) documentURL(documentPath string) string {
|
||||||
|
if s.publicOrigin == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.publicOrigin + "/" + documentPath
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) broadcastNotification(userID string) {
|
func (s *Service) broadcastNotification(userID string) {
|
||||||
if s.hub == nil {
|
if s.hub == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -38,9 +38,31 @@ type AuthConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
SMTPHost string `json:"smtpHost"`
|
// Provider selects the sender implementation. Valid values: "noop",
|
||||||
SMTPPort int `json:"smtpPort"`
|
// "smtp", "postmark". When empty, the loader infers from the other
|
||||||
From string `json:"from"`
|
// fields: PostmarkToken wins, then SMTPHost, then NoOp.
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
SMTPHost string `json:"smtpHost"`
|
||||||
|
SMTPPort int `json:"smtpPort"`
|
||||||
|
PostmarkToken string `json:"postmarkToken"`
|
||||||
|
PostmarkAPIURL string `json:"postmarkApiUrl"`
|
||||||
|
From string `json:"from"`
|
||||||
|
InstanceName string `json:"instanceName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedProvider returns the concrete provider choice after applying the
|
||||||
|
// inference rule. Operators can set Provider explicitly to force a choice.
|
||||||
|
func (e EmailConfig) ResolvedProvider() string {
|
||||||
|
if e.Provider != "" {
|
||||||
|
return e.Provider
|
||||||
|
}
|
||||||
|
if e.PostmarkToken != "" {
|
||||||
|
return "postmark"
|
||||||
|
}
|
||||||
|
if e.SMTPHost != "" {
|
||||||
|
return "smtp"
|
||||||
|
}
|
||||||
|
return "noop"
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -59,9 +81,10 @@ func Default() Config {
|
|||||||
PublicOrigin: "http://localhost:8080",
|
PublicOrigin: "http://localhost:8080",
|
||||||
},
|
},
|
||||||
Email: EmailConfig{
|
Email: EmailConfig{
|
||||||
SMTPHost: "localhost",
|
SMTPHost: "localhost",
|
||||||
SMTPPort: 1025,
|
SMTPPort: 1025,
|
||||||
From: "notifications@cairnquire.local",
|
From: "notifications@cairnquire.local",
|
||||||
|
InstanceName: "Cairnquire",
|
||||||
},
|
},
|
||||||
LogLevel: "INFO",
|
LogLevel: "INFO",
|
||||||
}
|
}
|
||||||
@@ -87,6 +110,10 @@ func Load() (Config, error) {
|
|||||||
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
|
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
|
||||||
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")
|
||||||
|
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
|
||||||
|
overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL")
|
||||||
|
overrideString(&cfg.Email.Provider, "CAIRNQUIRE_EMAIL_PROVIDER")
|
||||||
|
overrideString(&cfg.Email.InstanceName, "CAIRNQUIRE_INSTANCE_NAME")
|
||||||
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
|
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
|
||||||
if p, err := strconv.Atoi(port); err == nil {
|
if p, err := strconv.Atoi(port); err == nil {
|
||||||
cfg.Email.SMTPPort = p
|
cfg.Email.SMTPPort = p
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_email_queue_notification;
|
||||||
|
DROP INDEX IF EXISTS idx_email_queue_status;
|
||||||
|
DROP TABLE IF EXISTS email_queue;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS email_queue (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
notification_id TEXT,
|
||||||
|
to_address TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'failed', 'skipped')),
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||||
|
last_error TEXT,
|
||||||
|
next_attempt_at TEXT NOT NULL,
|
||||||
|
sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(notification_id) REFERENCES notifications(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, next_attempt_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_queue_notification ON email_queue(notification_id);
|
||||||
29
apps/server/internal/email/collaboration_adapter.go
Normal file
29
apps/server/internal/email/collaboration_adapter.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import "github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||||
|
|
||||||
|
// CollaborationRenderer adapts email.Render to the collaboration.EmailRenderer
|
||||||
|
// interface by converting collaboration.EmailData into email.TemplateData.
|
||||||
|
type CollaborationRenderer struct {
|
||||||
|
InstanceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render satisfies collaboration.EmailRenderer.
|
||||||
|
func (c CollaborationRenderer) Render(kind string, data collaboration.EmailData) (string, string, error) {
|
||||||
|
td := TemplateData{
|
||||||
|
ActorName: data.ActorName,
|
||||||
|
ActorEmail: data.ActorEmail,
|
||||||
|
DocumentPath: data.DocumentPath,
|
||||||
|
DocumentTitle: data.DocumentTitle,
|
||||||
|
CommentBody: data.CommentBody,
|
||||||
|
MentionText: data.MentionText,
|
||||||
|
ConflictDesc: data.ConflictDesc,
|
||||||
|
ViewURL: data.ViewURL,
|
||||||
|
UnsubURL: data.UnsubURL,
|
||||||
|
InstanceName: c.InstanceName,
|
||||||
|
}
|
||||||
|
if td.InstanceName == "" {
|
||||||
|
td.InstanceName = "Cairnquire"
|
||||||
|
}
|
||||||
|
return Render(kind, td)
|
||||||
|
}
|
||||||
14
apps/server/internal/email/internal.go
Normal file
14
apps/server/internal/email/internal.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
func randomToken(length int) (string, error) {
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
141
apps/server/internal/email/postmark.go
Normal file
141
apps/server/internal/email/postmark.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPostmarkURL = "https://api.postmarkapp.com"
|
||||||
|
postmarkTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostmarkSender sends email via the Postmark API.
|
||||||
|
type PostmarkSender struct {
|
||||||
|
serverToken string
|
||||||
|
apiURL string
|
||||||
|
from string
|
||||||
|
client *http.Client
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostmarkSender constructs a Postmark sender. apiURL may be empty to use
|
||||||
|
// the public Postmark endpoint; tests can point it at an httptest server.
|
||||||
|
func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *PostmarkSender {
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = defaultPostmarkURL
|
||||||
|
}
|
||||||
|
return &PostmarkSender{
|
||||||
|
serverToken: serverToken,
|
||||||
|
apiURL: strings.TrimRight(apiURL, "/"),
|
||||||
|
from: from,
|
||||||
|
client: &http.Client{Timeout: postmarkTimeout},
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type postmarkRequest struct {
|
||||||
|
From string `json:"From"`
|
||||||
|
To string `json:"To"`
|
||||||
|
Subject string `json:"Subject"`
|
||||||
|
TextBody string `json:"TextBody"`
|
||||||
|
Headers []postmarkHeader `json:"Headers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type postmarkHeader struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Value string `json:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type postmarkResponse struct {
|
||||||
|
ErrorCode int `json:"ErrorCode"`
|
||||||
|
Message string `json:"Message"`
|
||||||
|
To string `json:"To"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithReplyTo sets a Reply-To header on the outgoing message. Returns a copy
|
||||||
|
// of the sender with the reply-to address applied for the next Send call.
|
||||||
|
// Not safe for concurrent use; callers should construct a fresh sender per
|
||||||
|
// send if they need different reply-to addresses.
|
||||||
|
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
if p.serverToken == "" {
|
||||||
|
return fmt.Errorf("postmark server token not configured")
|
||||||
|
}
|
||||||
|
if p.from == "" {
|
||||||
|
return fmt.Errorf("postmark from address not configured")
|
||||||
|
}
|
||||||
|
if len(to) == 0 {
|
||||||
|
return fmt.Errorf("postmark send requires at least one recipient")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := postmarkRequest{
|
||||||
|
From: p.from,
|
||||||
|
To: strings.Join(to, ","),
|
||||||
|
Subject: subject,
|
||||||
|
TextBody: body,
|
||||||
|
}
|
||||||
|
bodyBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal postmark request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiURL+"/email", bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build postmark request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Postmark-Server-Token", p.serverToken)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("postmark request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
|
||||||
|
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
return &TemporaryError{Cause: fmt.Errorf("postmark server error: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed postmarkResponse
|
||||||
|
if err := json.Unmarshal(respBytes, &parsed); err != nil {
|
||||||
|
return fmt.Errorf("decode postmark response: %w", err)
|
||||||
|
}
|
||||||
|
if parsed.ErrorCode != 0 {
|
||||||
|
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
|
||||||
|
// 406 as non-retryable; others bubble up as generic failures.
|
||||||
|
return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string) string {
|
||||||
|
if len(s) > 256 {
|
||||||
|
return s[:256] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemporaryError wraps a failure that callers may retry. The queue worker
|
||||||
|
// uses errors.As to detect it and apply exponential backoff.
|
||||||
|
type TemporaryError struct {
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TemporaryError) Error() string { return t.Cause.Error() }
|
||||||
|
func (t *TemporaryError) Unwrap() error { return t.Cause }
|
||||||
113
apps/server/internal/email/postmark_test.go
Normal file
113
apps/server/internal/email/postmark_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostmarkSender_Send(t *testing.T) {
|
||||||
|
var gotBody postmarkRequest
|
||||||
|
var gotToken string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotToken = r.Header.Get("X-Postmark-Server-Token")
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(body, &gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK","To":"alice@example.com"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("test-token", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if gotToken != "test-token" {
|
||||||
|
t.Errorf("token = %q, want test-token", gotToken)
|
||||||
|
}
|
||||||
|
if gotBody.From != "noreply@example.com" {
|
||||||
|
t.Errorf("from = %q", gotBody.From)
|
||||||
|
}
|
||||||
|
if gotBody.To != "alice@example.com" {
|
||||||
|
t.Errorf("to = %q", gotBody.To)
|
||||||
|
}
|
||||||
|
if gotBody.Subject != "Hello" {
|
||||||
|
t.Errorf("subject = %q", gotBody.Subject)
|
||||||
|
}
|
||||||
|
if gotBody.TextBody != "World" {
|
||||||
|
t.Errorf("body = %q", gotBody.TextBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_ErrorCode(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ErrorCode":406,"Message":"Inactive recipient"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "406") {
|
||||||
|
t.Errorf("error should mention code 406: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_ServerError_Temporary(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
var temp *TemporaryError
|
||||||
|
if !errors.As(err, &temp) {
|
||||||
|
t.Errorf("expected TemporaryError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_Validation(t *testing.T) {
|
||||||
|
sender := NewPostmarkSender("", "noreply@example.com", "", testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for missing token")
|
||||||
|
}
|
||||||
|
sender = NewPostmarkSender("tok", "", "", testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for missing from")
|
||||||
|
}
|
||||||
|
sender = NewPostmarkSender("tok", "noreply@example.com", "", testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), nil, "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for empty recipients")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_MultipleRecipients(t *testing.T) {
|
||||||
|
var gotBody postmarkRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(body, &gotBody)
|
||||||
|
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"a@x.com", "b@x.com"}, "s", "b"); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotBody.To, "a@x.com") || !strings.Contains(gotBody.To, "b@x.com") {
|
||||||
|
t.Errorf("to = %q, expected both recipients", gotBody.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
344
apps/server/internal/email/queue.go
Normal file
344
apps/server/internal/email/queue.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Queue stores outgoing emails durably and dispatches them via a Sender.
|
||||||
|
// It is safe for concurrent use; claim uses an atomic UPDATE ... WHERE
|
||||||
|
// status='pending' to lease rows without races.
|
||||||
|
type Queue struct {
|
||||||
|
db *sql.DB
|
||||||
|
sender Sender
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
pollInterval time.Duration
|
||||||
|
maxBackoff time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueRow is the on-disk representation of a queued email.
|
||||||
|
type QueueRow struct {
|
||||||
|
ID string
|
||||||
|
NotificationID string
|
||||||
|
ToAddress string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
Status string
|
||||||
|
Attempts int
|
||||||
|
MaxAttempts int
|
||||||
|
LastError string
|
||||||
|
NextAttemptAt time.Time
|
||||||
|
SentAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQueue constructs a queue. sender may be nil; in that case emails stay
|
||||||
|
// pending forever (useful for tests that only inspect enqueue state).
|
||||||
|
func NewQueue(db *sql.DB, sender Sender, logger *slog.Logger) *Queue {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Queue{
|
||||||
|
db: db,
|
||||||
|
sender: sender,
|
||||||
|
logger: logger,
|
||||||
|
pollInterval: 10 * time.Second,
|
||||||
|
maxBackoff: 30 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPollInterval overrides the worker poll interval. Must be called before
|
||||||
|
// Run. Useful for tests.
|
||||||
|
func (q *Queue) SetPollInterval(d time.Duration) {
|
||||||
|
if d > 0 {
|
||||||
|
q.pollInterval = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxBackoff overrides the maximum retry backoff.
|
||||||
|
func (q *Queue) SetMaxBackoff(d time.Duration) {
|
||||||
|
if d > 0 {
|
||||||
|
q.maxBackoff = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue inserts a new outgoing email. notificationID may be empty. The
|
||||||
|
// email is immediately eligible for delivery (next_attempt_at = now).
|
||||||
|
func (q *Queue) Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error) {
|
||||||
|
if to == "" {
|
||||||
|
return "", fmt.Errorf("enqueue: to address is required")
|
||||||
|
}
|
||||||
|
if subject == "" {
|
||||||
|
return "", fmt.Errorf("enqueue: subject is required")
|
||||||
|
}
|
||||||
|
id, err := randomQueueID()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if _, err := q.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO email_queue (id, notification_id, to_address, subject, body, status, attempts, max_attempts, next_attempt_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pending', 0, 3, ?, ?)
|
||||||
|
`, id, nullableString(notificationID), to, subject, body, formatTime(now), formatTime(now)); err != nil {
|
||||||
|
return "", fmt.Errorf("enqueue email: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationEmailed stamps notifications.emailed_at for the given
|
||||||
|
// notification id. Called by the worker once the email is dispatched.
|
||||||
|
func (q *Queue) MarkNotificationEmailed(ctx context.Context, notificationID string) error {
|
||||||
|
if notificationID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE notifications SET emailed_at = ? WHERE id = ?
|
||||||
|
`, formatTime(time.Now().UTC()), notificationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark notification emailed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the worker loop. It blocks until ctx is cancelled.
|
||||||
|
func (q *Queue) Run(ctx context.Context) {
|
||||||
|
q.logger.Info("email queue worker started", "poll_interval", q.pollInterval)
|
||||||
|
ticker := time.NewTicker(q.pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
q.logger.Info("email queue worker stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := q.processBatch(ctx); err != nil {
|
||||||
|
q.logger.Warn("email queue batch failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processBatch claims and sends up to 25 pending emails per tick.
|
||||||
|
func (q *Queue) processBatch(ctx context.Context) error {
|
||||||
|
if q.sender == nil {
|
||||||
|
// No sender configured; leave rows pending so a future sender can
|
||||||
|
// pick them up once wired.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := q.claimBatch(ctx, 25)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("claim batch: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := q.deliver(ctx, row); err != nil {
|
||||||
|
q.logger.Warn("deliver email failed", "id", row.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimBatch atomically leases pending rows by flipping their status to
|
||||||
|
// 'pending' but bumping attempts so a concurrent worker cannot re-claim
|
||||||
|
// them until the next_attempt_at expires. We use a single UPDATE with a
|
||||||
|
// subquery to keep this race-free under SQLite.
|
||||||
|
func (q *Queue) claimBatch(ctx context.Context, limit int) ([]QueueRow, error) {
|
||||||
|
tx, err := q.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin claim: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
|
||||||
|
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
|
||||||
|
FROM email_queue
|
||||||
|
WHERE status = 'pending' AND next_attempt_at <= ?
|
||||||
|
ORDER BY next_attempt_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, formatTime(time.Now().UTC()), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query pending: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claimed []QueueRow
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := scanQueueRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claimed = append(claimed, row)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range claimed {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET attempts = attempts + 1 WHERE id = ?
|
||||||
|
`, row.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("lease row %s: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit claim: %w", err)
|
||||||
|
}
|
||||||
|
return claimed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver attempts one email. On success it marks the row sent and stamps
|
||||||
|
// the linked notification. On failure it schedules a retry with exponential
|
||||||
|
// backoff or marks the row failed if attempts are exhausted.
|
||||||
|
func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
|
||||||
|
err := q.sender.Send(ctx, []string{row.ToAddress}, row.Subject, row.Body)
|
||||||
|
if err == nil {
|
||||||
|
if _, err := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET status = 'sent', sent_at = ?, last_error = '' WHERE id = ?
|
||||||
|
`, formatTime(time.Now().UTC()), row.ID); err != nil {
|
||||||
|
return fmt.Errorf("mark sent: %w", err)
|
||||||
|
}
|
||||||
|
if err := q.MarkNotificationEmailed(ctx, row.NotificationID); err != nil {
|
||||||
|
q.logger.Warn("mark notification emailed", "id", row.NotificationID, "error", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure: retry or give up.
|
||||||
|
if row.Attempts >= row.MaxAttempts {
|
||||||
|
if _, ferr := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
|
||||||
|
`, truncateErr(err.Error()), row.ID); ferr != nil {
|
||||||
|
return fmt.Errorf("mark failed: %w", ferr)
|
||||||
|
}
|
||||||
|
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", row.Attempts, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff := q.backoff(row.Attempts)
|
||||||
|
next := time.Now().UTC().Add(backoff)
|
||||||
|
if _, err := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
|
||||||
|
`, formatTime(next), truncateErr(err.Error()), row.ID); err != nil {
|
||||||
|
return fmt.Errorf("schedule retry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemporaryError (Postmark 5xx) is retried with the same backoff as
|
||||||
|
// other errors; the max-attempts cap still bounds total work.
|
||||||
|
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) backoff(attempts int) time.Duration {
|
||||||
|
// 2^attempts seconds, capped: 2s, 4s, 8s, 16s, ... up to maxBackoff.
|
||||||
|
d := time.Duration(math.Pow(2, float64(attempts))) * time.Second
|
||||||
|
if d > q.maxBackoff {
|
||||||
|
d = q.maxBackoff
|
||||||
|
}
|
||||||
|
if d < time.Second {
|
||||||
|
d = time.Second
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingCount returns the number of rows in pending status, primarily for
|
||||||
|
// tests and operator dashboards.
|
||||||
|
func (q *Queue) PendingCount(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM email_queue WHERE status = 'pending'`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFailed returns rows that have exhausted their retries, for operator
|
||||||
|
// inspection.
|
||||||
|
func (q *Queue) ListFailed(ctx context.Context, limit int) ([]QueueRow, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, `
|
||||||
|
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
|
||||||
|
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
|
||||||
|
FROM email_queue
|
||||||
|
WHERE status = 'failed'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []QueueRow
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := scanQueueRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanQueueRow(rows *sql.Rows) (QueueRow, error) {
|
||||||
|
var r QueueRow
|
||||||
|
var sentAt, lastError string
|
||||||
|
var nextAttempt, createdAt string
|
||||||
|
if err := rows.Scan(&r.ID, &r.NotificationID, &r.ToAddress, &r.Subject, &r.Body, &r.Status,
|
||||||
|
&r.Attempts, &r.MaxAttempts, &lastError, &nextAttempt, &sentAt, &createdAt); err != nil {
|
||||||
|
return QueueRow{}, fmt.Errorf("scan queue row: %w", err)
|
||||||
|
}
|
||||||
|
r.LastError = lastError
|
||||||
|
var err error
|
||||||
|
r.NextAttemptAt, err = time.Parse(time.RFC3339, nextAttempt)
|
||||||
|
if err != nil {
|
||||||
|
return QueueRow{}, fmt.Errorf("parse next_attempt_at: %w", err)
|
||||||
|
}
|
||||||
|
r.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return QueueRow{}, fmt.Errorf("parse created_at: %w", err)
|
||||||
|
}
|
||||||
|
if sentAt != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, sentAt)
|
||||||
|
if err == nil {
|
||||||
|
r.SentAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateErr(s string) string {
|
||||||
|
if len(s) > 512 {
|
||||||
|
return s[:512] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomQueueID produces an id with enough entropy to avoid collisions. We
|
||||||
|
// keep a small helper in-package to avoid an import cycle on auth.
|
||||||
|
func randomQueueID() (string, error) {
|
||||||
|
id, err := randomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "email:" + id, nil
|
||||||
|
}
|
||||||
248
apps/server/internal/email/queue_test.go
Normal file
248
apps/server/internal/email/queue_test.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.DatabaseConfig{Path: t.TempDir() + "/test.sqlite"}
|
||||||
|
handle, err := database.Open(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = handle.Close() })
|
||||||
|
db := handle.SQL()
|
||||||
|
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
// Seed a user + notification so the queue's notification_id FK is
|
||||||
|
// satisfiable and MarkNotificationEmailed can update a real row.
|
||||||
|
_, err = db.Exec(`INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) VALUES ('user:alice@example.com', 'alice@example.com', 'Alice', 'admin', ?, ?)`,
|
||||||
|
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, created_at) VALUES (?, 'user:alice@example.com', 'file_changed', 'document', 'doc:test', 'test', ?)`,
|
||||||
|
"notif:test-1", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed notification: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_EnqueueAndPendingCount(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
q := NewQueue(db, nil, testLogger(t))
|
||||||
|
|
||||||
|
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Subject", "Body")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected non-empty id")
|
||||||
|
}
|
||||||
|
count, err := q.PendingCount(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PendingCount: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("pending = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_EnqueueValidation(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
q := NewQueue(db, nil, testLogger(t))
|
||||||
|
|
||||||
|
// Empty notification_id is allowed (the column is nullable).
|
||||||
|
if _, err := q.Enqueue(context.Background(), "", "alice@example.com", "s", "b"); err != nil {
|
||||||
|
t.Fatalf("empty notification_id should be allowed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "", "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for empty to")
|
||||||
|
}
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for empty subject")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingSender struct {
|
||||||
|
sends []recordedSend
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordedSend struct {
|
||||||
|
to []string
|
||||||
|
subject string
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
if r.err != nil {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
r.sends = append(r.sends, recordedSend{to: to, subject: subject, body: body})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_DeliverSuccess(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
sender := &recordingSender{}
|
||||||
|
q := NewQueue(db, sender, testLogger(t))
|
||||||
|
q.SetPollInterval(20 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Hello", "World"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
if err := waitFor(ctx, func() bool {
|
||||||
|
n, _ := q.PendingCount(ctx)
|
||||||
|
return n == 0
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("email never sent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sender.sends) != 1 {
|
||||||
|
t.Fatalf("sends = %d, want 1", len(sender.sends))
|
||||||
|
}
|
||||||
|
if sender.sends[0].subject != "Hello" {
|
||||||
|
t.Errorf("subject = %q", sender.sends[0].subject)
|
||||||
|
}
|
||||||
|
if sender.sends[0].body != "World" {
|
||||||
|
t.Errorf("body = %q", sender.sends[0].body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The linked notification should now have emailed_at stamped.
|
||||||
|
var emailedAt string
|
||||||
|
if err := db.QueryRow(`SELECT COALESCE(emailed_at, '') FROM notifications WHERE id = ?`, "notif:test-1").Scan(&emailedAt); err != nil {
|
||||||
|
t.Fatalf("query emailed_at: %v", err)
|
||||||
|
}
|
||||||
|
if emailedAt == "" {
|
||||||
|
t.Error("expected notifications.emailed_at to be set after send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_RetryThenSucceed(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
// Fail twice, then succeed.
|
||||||
|
sender := &flakySender{failTimes: 2}
|
||||||
|
q := NewQueue(db, sender, testLogger(t))
|
||||||
|
q.SetPollInterval(15 * time.Millisecond)
|
||||||
|
q.SetMaxBackoff(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Retry", "Body"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
if err := waitFor(ctx, func() bool {
|
||||||
|
return sender.successCount >= 1
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the row is marked sent.
|
||||||
|
var status string
|
||||||
|
if err := db.QueryRow(`SELECT status FROM email_queue WHERE to_address = ?`, "alice@example.com").Scan(&status); err != nil {
|
||||||
|
t.Fatalf("query status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "sent" {
|
||||||
|
t.Errorf("status = %q, want sent", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_PermanentFailure(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
sender := &recordingSender{err: errors.New("permanent Postmark rejection")}
|
||||||
|
q := NewQueue(db, sender, testLogger(t))
|
||||||
|
q.SetPollInterval(15 * time.Millisecond)
|
||||||
|
q.SetMaxBackoff(20 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
if err := waitFor(ctx, func() bool {
|
||||||
|
failed, _ := q.ListFailed(ctx, 10)
|
||||||
|
return len(failed) == 1
|
||||||
|
}); err != nil {
|
||||||
|
failed, _ := q.ListFailed(ctx, 10)
|
||||||
|
t.Fatalf("expected 1 failed row, got %d: %v", len(failed), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, _ := q.ListFailed(ctx, 10)
|
||||||
|
if !strings.Contains(failed[0].LastError, "permanent") {
|
||||||
|
t.Errorf("last_error = %q", failed[0].LastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_NilSenderLeavesPending(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
q := NewQueue(db, nil, testLogger(t))
|
||||||
|
q.SetPollInterval(15 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Pending", "Body"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
count, _ := q.PendingCount(context.Background())
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected row to stay pending with nil sender, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flakySender fails the first N attempts then succeeds.
|
||||||
|
type flakySender struct {
|
||||||
|
failTimes int
|
||||||
|
attempts int
|
||||||
|
successCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
f.attempts++
|
||||||
|
if f.attempts <= f.failTimes {
|
||||||
|
return errors.New("transient failure")
|
||||||
|
}
|
||||||
|
f.successCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(ctx context.Context, cond func() bool) error {
|
||||||
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if cond() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
apps/server/internal/email/templates.go
Normal file
149
apps/server/internal/email/templates.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplateData carries the context required to render any notification email.
|
||||||
|
// Fields are optional per template; callers should populate what they have.
|
||||||
|
type TemplateData struct {
|
||||||
|
ActorName string
|
||||||
|
ActorEmail string
|
||||||
|
DocumentPath string
|
||||||
|
DocumentTitle string
|
||||||
|
CommentBody string
|
||||||
|
MentionText string
|
||||||
|
ConflictDesc string
|
||||||
|
ViewURL string
|
||||||
|
UnsubURL string
|
||||||
|
InstanceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render returns a plain-text subject and body for the given notification
|
||||||
|
// type. Subjects are short and prefixed with the document path so email
|
||||||
|
// clients thread per-document.
|
||||||
|
func Render(kind string, data TemplateData) (subject, body string, err error) {
|
||||||
|
if data.InstanceName == "" {
|
||||||
|
data.InstanceName = "Cairnquire"
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "file_changed":
|
||||||
|
return renderFileChanged(data)
|
||||||
|
case "comment":
|
||||||
|
return renderComment(data)
|
||||||
|
case "mention":
|
||||||
|
return renderMention(data)
|
||||||
|
case "conflict":
|
||||||
|
return renderConflict(data)
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unknown email template kind %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subjectPrefix(data TemplateData) string {
|
||||||
|
if data.DocumentPath != "" {
|
||||||
|
return "[" + data.DocumentPath + "]"
|
||||||
|
}
|
||||||
|
return "[" + data.InstanceName + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func footer(data TemplateData) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\n--\n")
|
||||||
|
if data.ViewURL != "" {
|
||||||
|
fmt.Fprintf(&b, "View online: %s\n", data.ViewURL)
|
||||||
|
}
|
||||||
|
if data.UnsubURL != "" {
|
||||||
|
fmt.Fprintf(&b, "Unsubscribe: %s\n", data.UnsubURL)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "You receive this because you watch this document on %s.\n", data.InstanceName)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFileChanged(data TemplateData) (string, string, error) {
|
||||||
|
title := data.DocumentTitle
|
||||||
|
if title == "" {
|
||||||
|
title = data.DocumentPath
|
||||||
|
}
|
||||||
|
actor := data.ActorName
|
||||||
|
if actor == "" {
|
||||||
|
actor = "Someone"
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("%s Updated by %s", subjectPrefix(data), actor)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s updated %q at %s UTC.\n", actor, title, time.Now().UTC().Format("2006-01-02 15:04"))
|
||||||
|
if data.DocumentPath != "" {
|
||||||
|
fmt.Fprintf(&b, "Path: %s\n", data.DocumentPath)
|
||||||
|
}
|
||||||
|
b.WriteString("\nView the changes online to see what is new.\n")
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderComment(data TemplateData) (string, string, error) {
|
||||||
|
actor := data.ActorName
|
||||||
|
if actor == "" {
|
||||||
|
actor = "Someone"
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("%s Comment from %s", subjectPrefix(data), actor)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s commented on %q:\n", actor, docLabel(data))
|
||||||
|
b.WriteString("\n")
|
||||||
|
if data.CommentBody != "" {
|
||||||
|
// Quote the comment body, line by line, as plain text.
|
||||||
|
for _, line := range strings.Split(data.CommentBody, "\n") {
|
||||||
|
fmt.Fprintf(&b, "> %s\n", line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString("> (no body)\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\nReply to this email to respond.\n")
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMention(data TemplateData) (string, string, error) {
|
||||||
|
actor := data.ActorName
|
||||||
|
if actor == "" {
|
||||||
|
actor = "Someone"
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("%s Mention from %s", subjectPrefix(data), actor)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s mentioned you on %q.\n", actor, docLabel(data))
|
||||||
|
if data.MentionText != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, line := range strings.Split(data.MentionText, "\n") {
|
||||||
|
fmt.Fprintf(&b, "> %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderConflict(data TemplateData) (string, string, error) {
|
||||||
|
subject := fmt.Sprintf("%s Sync conflict", subjectPrefix(data))
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "A sync conflict was detected on %q.\n", docLabel(data))
|
||||||
|
if data.ConflictDesc != "" {
|
||||||
|
fmt.Fprintf(&b, "\nDetails: %s\n", data.ConflictDesc)
|
||||||
|
}
|
||||||
|
b.WriteString("\nOpen the document to review and resolve the conflict.\n")
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func docLabel(data TemplateData) string {
|
||||||
|
if data.DocumentTitle != "" {
|
||||||
|
return data.DocumentTitle
|
||||||
|
}
|
||||||
|
if data.DocumentPath != "" {
|
||||||
|
return data.DocumentPath
|
||||||
|
}
|
||||||
|
return "a document"
|
||||||
|
}
|
||||||
119
apps/server/internal/email/templates_test.go
Normal file
119
apps/server/internal/email/templates_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRender_FileChanged(t *testing.T) {
|
||||||
|
subject, body, err := Render("file_changed", TemplateData{
|
||||||
|
ActorName: "Alice",
|
||||||
|
DocumentPath: "docs/api.md",
|
||||||
|
DocumentTitle: "API Reference",
|
||||||
|
ViewURL: "https://example.com/docs/api.md",
|
||||||
|
UnsubURL: "https://example.com/unsub/123",
|
||||||
|
InstanceName: "Cairnquire",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "docs/api.md") {
|
||||||
|
t.Errorf("subject should include path: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Alice") {
|
||||||
|
t.Errorf("subject should include actor: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Alice") {
|
||||||
|
t.Errorf("body should mention actor: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "API Reference") {
|
||||||
|
t.Errorf("body should mention title: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://example.com/docs/api.md") {
|
||||||
|
t.Errorf("body should include view url: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://example.com/unsub/123") {
|
||||||
|
t.Errorf("body should include unsub url: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_Comment(t *testing.T) {
|
||||||
|
subject, body, err := Render("comment", TemplateData{
|
||||||
|
ActorName: "Bob",
|
||||||
|
DocumentTitle: "Getting Started",
|
||||||
|
CommentBody: "This step is unclear.\nCan we add an example?",
|
||||||
|
ViewURL: "https://example.com/docs/getting-started",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Bob") {
|
||||||
|
t.Errorf("subject should include actor: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "> This step is unclear.") {
|
||||||
|
t.Errorf("body should quote comment: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "> Can we add an example?") {
|
||||||
|
t.Errorf("body should quote second line: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Reply to this email") {
|
||||||
|
t.Errorf("body should mention reply-to-respond: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_Mention(t *testing.T) {
|
||||||
|
subject, body, err := Render("mention", TemplateData{
|
||||||
|
ActorName: "Carol",
|
||||||
|
DocumentTitle: "Spec",
|
||||||
|
MentionText: "@dan please review",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Mention") {
|
||||||
|
t.Errorf("subject: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "> @dan please review") {
|
||||||
|
t.Errorf("body should quote mention: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_Conflict(t *testing.T) {
|
||||||
|
subject, body, err := Render("conflict", TemplateData{
|
||||||
|
DocumentPath: "notes/2024.md",
|
||||||
|
ConflictDesc: "Local and remote both edited line 12",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "conflict") {
|
||||||
|
t.Errorf("subject: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "sync conflict") {
|
||||||
|
t.Errorf("body should mention conflict: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "line 12") {
|
||||||
|
t.Errorf("body should include conflict desc: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_UnknownKind(t *testing.T) {
|
||||||
|
_, _, err := Render("bogus", TemplateData{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_PlainTextOnly(t *testing.T) {
|
||||||
|
for _, kind := range []string{"file_changed", "comment", "mention", "conflict"} {
|
||||||
|
_, body, err := Render(kind, TemplateData{
|
||||||
|
ActorName: "A", DocumentTitle: "T", CommentBody: "<b>html</b>",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render %s: %v", kind, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "<html>") || strings.Contains(body, "<body>") {
|
||||||
|
t.Errorf("template %s produced HTML: %q", kind, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/server/internal/email/test_helpers_test.go
Normal file
18
apps/server/internal/email/test_helpers_test.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger(t *testing.T) *slog.Logger {
|
||||||
|
t.Helper()
|
||||||
|
return slog.New(slog.NewTextHandler(&testWriter{t: t}, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
}
|
||||||
|
|
||||||
|
type testWriter struct{ t *testing.T }
|
||||||
|
|
||||||
|
func (w *testWriter) Write(p []byte) (int, error) {
|
||||||
|
w.t.Logf("%s", p)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -301,6 +301,54 @@ func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordUserCanBeginPasskeyRegistrationFromAccount(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
|
||||||
|
login := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
"password": "very secure password",
|
||||||
|
}))
|
||||||
|
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||||
|
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||||
|
|
||||||
|
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||||
|
accountRequest.AddCookie(session)
|
||||||
|
account := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(account, accountRequest)
|
||||||
|
if account.Code != http.StatusOK || !bytes.Contains(account.Body.Bytes(), []byte("data-passkey-add")) {
|
||||||
|
t.Fatalf("account page response = %d %q, want add-passkey form", account.Code, account.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
beginRequest := jsonRequest(http.MethodPost, "/api/auth/passkeys/me/register/begin", map[string]any{})
|
||||||
|
beginRequest.AddCookie(session)
|
||||||
|
begin := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(begin, beginRequest)
|
||||||
|
if begin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("passkey begin response = %d %q", begin.Code, begin.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ChallengeID string `json:"challengeId"`
|
||||||
|
Options any `json:"options"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(begin.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode passkey begin response: %v", err)
|
||||||
|
}
|
||||||
|
if body.ChallengeID == "" || body.Options == nil {
|
||||||
|
t.Fatalf("passkey begin body = %#v, want challenge and options", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicBegin := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(publicBegin, jsonRequest(http.MethodPost, "/api/auth/passkeys/register/begin", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
}))
|
||||||
|
if publicBegin.Code == http.StatusOK {
|
||||||
|
t.Fatalf("public passkey begin response = %d %q, want failure for existing account", publicBegin.Code, publicBegin.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
||||||
server := newAPITestServer(t)
|
server := newAPITestServer(t)
|
||||||
sender := &testEmailSender{}
|
sender := &testEmailSender{}
|
||||||
@@ -552,6 +600,48 @@ func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPISyncSnapshotHandlesUnindexedDiskFilesByFolderPermission(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
viewer := server.viewerUser(t)
|
||||||
|
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(server.root, "content", "unindexed.md"), []byte("# Unindexed\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write unindexed document: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-1"})
|
||||||
|
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
initRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(initRecorder.Body.String(), "sql: no rows") {
|
||||||
|
t.Fatalf("sync init leaked missing document row: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(initRecorder.Body.String(), "unindexed.md") {
|
||||||
|
t.Fatalf("private unindexed document leaked before grant: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||||
|
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
|
||||||
|
{UserID: viewer.ID, Permission: auth.PermissionRead},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("grant root folder read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-2"})
|
||||||
|
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
initRecorder = httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(initRecorder.Body.String(), "unindexed.md") {
|
||||||
|
t.Fatalf("sync snapshot missing unindexed document after folder grant: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
|
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
|
||||||
server := newAPITestServer(t)
|
server := newAPITestServer(t)
|
||||||
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|||||||
@@ -334,6 +334,42 @@ func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Requ
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCurrentUserPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, challengeID, err := s.auth.BeginCurrentUserPasskeyRegistration(r.Context(), principal)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCurrentUserPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challengeID := r.URL.Query().Get("challengeId")
|
||||||
|
if challengeID == "" {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
challengeID = req.ChallengeID
|
||||||
|
}
|
||||||
|
if challengeID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.FinishCurrentUserPasskeyRegistration(r.Context(), principal, challengeID, r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.allowAuthAttempt(w, r) {
|
if !s.allowAuthAttempt(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
|||||||
case path == "/permissions" && method == http.MethodPost:
|
case path == "/permissions" && method == http.MethodPost:
|
||||||
return auth.ScopeDocsWrite, true
|
return auth.ScopeDocsWrite, true
|
||||||
case strings.HasPrefix(path, "/api/auth/"):
|
case strings.HasPrefix(path, "/api/auth/"):
|
||||||
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
|
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" || strings.HasPrefix(path, "/api/auth/passkeys/me/") {
|
||||||
return auth.ScopeDocsRead, true
|
return auth.ScopeDocsRead, true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -359,8 +359,30 @@ func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool {
|
|||||||
if principal.Role == auth.RoleAdmin {
|
if principal.Role == auth.RoleAdmin {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
folder := strings.TrimSuffix(pagePath, "/"+lastPathSegment(pagePath))
|
return s.canWriteFolder(r, documentFolder(pagePath))
|
||||||
return s.canWriteFolder(r, folder)
|
}
|
||||||
|
|
||||||
|
func (s *Server) canReadNewDocument(r *http.Request, pagePath string) bool {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.canReadFolder(r, documentFolder(pagePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canReadFolder(r *http.Request, folder string) bool {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
|
||||||
|
return err == nil && auth.PermissionAllows(permission, auth.PermissionRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) canWriteFolder(r *http.Request, folder string) bool {
|
func (s *Server) canWriteFolder(r *http.Request, folder string) bool {
|
||||||
@@ -395,6 +417,15 @@ func lastPathSegment(path string) string {
|
|||||||
return parts[len(parts)-1]
|
return parts[len(parts)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func documentFolder(path string) string {
|
||||||
|
trimmed := strings.Trim(path, "/")
|
||||||
|
segment := lastPathSegment(trimmed)
|
||||||
|
if segment == "" || segment == trimmed {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(trimmed, "/"+segment)
|
||||||
|
}
|
||||||
|
|
||||||
func queryEscape(value string) string {
|
func queryEscape(value string) string {
|
||||||
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
router.Post("/api/auth/logout", server.handleLogout)
|
router.Post("/api/auth/logout", server.handleLogout)
|
||||||
router.Post("/api/auth/password/change", server.handleChangePassword)
|
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||||
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
||||||
|
router.Post("/api/auth/passkeys/me/register/begin", server.handleCurrentUserPasskeyRegisterBegin)
|
||||||
|
router.Post("/api/auth/passkeys/me/register/finish", server.handleCurrentUserPasskeyRegisterFinish)
|
||||||
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
|
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
|
||||||
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
||||||
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
||||||
|
|||||||
@@ -251,6 +251,26 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-passkey-add]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const support = await checkPasskeySupport();
|
||||||
|
if (!support.supported) {
|
||||||
|
setStatus("[data-passkey-add-status]", support.reason, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (support.reason) {
|
||||||
|
console.warn("Passkey support:", support.reason);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const begin = await postJSON("/api/auth/passkeys/me/register/begin", {});
|
||||||
|
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
|
||||||
|
await postJSON(`/api/auth/passkeys/me/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||||
|
setStatus("[data-passkey-add-status]", "Passkey added. You can use it the next time you sign in.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-passkey-add-status]", webAuthnErrorMessage(error), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await postJSON("/api/auth/logout", {});
|
await postJSON("/api/auth/logout", {});
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 1.3 MiB |
67
apps/server/internal/httpserver/static/codemirror.bundle.js
Normal file
67
apps/server/internal/httpserver/static/codemirror.bundle.js
Normal file
File diff suppressed because one or more lines are too long
@@ -7,7 +7,11 @@
|
|||||||
const article = document.querySelector('[data-document-path]');
|
const article = document.querySelector('[data-document-path]');
|
||||||
const workspaceShell = article?.closest('.workspace-shell--document');
|
const workspaceShell = article?.closest('.workspace-shell--document');
|
||||||
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
||||||
|
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
|
||||||
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
||||||
|
const commentsAside = document.querySelector('[data-comments-aside]');
|
||||||
|
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
|
||||||
|
const commentsSheetClose = document.querySelector('[data-close-comments]');
|
||||||
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
|
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
|
||||||
const commentToggle = document.querySelector('[data-comment-toggle]');
|
const commentToggle = document.querySelector('[data-comment-toggle]');
|
||||||
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
|
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
|
||||||
@@ -165,60 +169,69 @@
|
|||||||
updateStripMarkers();
|
updateStripMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCommentsAside() {
|
function buildAsideItemFor(el) {
|
||||||
if (!commentsAsideList) return;
|
const comments = [];
|
||||||
commentsAsideList.innerHTML = '';
|
el.querySelectorAll('.comment-item').forEach(item => {
|
||||||
|
const meta = item.querySelector('.comment-meta');
|
||||||
|
const bodyEl = item.querySelector('.comment-body');
|
||||||
|
comments.push({
|
||||||
|
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
|
||||||
|
createdAt: meta?.querySelector('time')?.textContent || '',
|
||||||
|
content: bodyEl?.textContent || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!comments.length) return null;
|
||||||
|
|
||||||
|
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||||
|
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
|
||||||
|
|
||||||
|
const asideItem = document.createElement('div');
|
||||||
|
asideItem.className = 'comments-aside-item';
|
||||||
|
asideItem.dataset.anchorHash = el.dataset.anchorHash;
|
||||||
|
|
||||||
|
const targetEl = document.createElement('div');
|
||||||
|
targetEl.className = 'comments-aside-item__target';
|
||||||
|
targetEl.textContent = truncated || 'Commented section';
|
||||||
|
targetEl.addEventListener('click', () => {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
asideItem.appendChild(targetEl);
|
||||||
|
|
||||||
|
comments.forEach(c => {
|
||||||
|
const commentEl = document.createElement('div');
|
||||||
|
commentEl.className = 'comments-aside-item__comment';
|
||||||
|
commentEl.innerHTML = `
|
||||||
|
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
||||||
|
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
||||||
|
`;
|
||||||
|
asideItem.appendChild(commentEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
return asideItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommentsInto(container) {
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
const body = document.querySelector('.markdown-body');
|
const body = document.querySelector('.markdown-body');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|
||||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||||
if (!commentables.length) {
|
if (!commentables.length) {
|
||||||
commentsAsideList.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
container.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
commentables.forEach(el => {
|
commentables.forEach(el => {
|
||||||
const comments = [];
|
const item = buildAsideItemFor(el);
|
||||||
el.querySelectorAll('.comment-item').forEach(item => {
|
if (item) container.appendChild(item);
|
||||||
const meta = item.querySelector('.comment-meta');
|
|
||||||
const bodyEl = item.querySelector('.comment-body');
|
|
||||||
comments.push({
|
|
||||||
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
|
|
||||||
createdAt: meta?.querySelector('time')?.textContent || '',
|
|
||||||
content: bodyEl?.textContent || '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (!comments.length) return;
|
|
||||||
|
|
||||||
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
||||||
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
|
|
||||||
|
|
||||||
const asideItem = document.createElement('div');
|
|
||||||
asideItem.className = 'comments-aside-item';
|
|
||||||
asideItem.dataset.anchorHash = el.dataset.anchorHash;
|
|
||||||
|
|
||||||
const targetEl = document.createElement('div');
|
|
||||||
targetEl.className = 'comments-aside-item__target';
|
|
||||||
targetEl.textContent = truncated || 'Commented section';
|
|
||||||
targetEl.addEventListener('click', () => {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
asideItem.appendChild(targetEl);
|
|
||||||
|
|
||||||
comments.forEach(c => {
|
|
||||||
const commentEl = document.createElement('div');
|
|
||||||
commentEl.className = 'comments-aside-item__comment';
|
|
||||||
commentEl.innerHTML = `
|
|
||||||
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
|
||||||
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
|
||||||
`;
|
|
||||||
asideItem.appendChild(commentEl);
|
|
||||||
});
|
|
||||||
|
|
||||||
commentsAsideList.appendChild(asideItem);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommentsAside() {
|
||||||
|
renderCommentsInto(commentsAsideList);
|
||||||
|
renderCommentsInto(commentsAsideListMobile);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStripMarkers() {
|
function updateStripMarkers() {
|
||||||
@@ -326,6 +339,45 @@
|
|||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commentsSheetOpen() {
|
||||||
|
return commentsAside?.classList.contains('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCommentsSheetOpen(open) {
|
||||||
|
if (!commentsAside) return;
|
||||||
|
commentsAside.classList.toggle('is-open', open);
|
||||||
|
if (commentsSheetToggle) {
|
||||||
|
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
document.body.classList.add('drawer-open');
|
||||||
|
renderCommentsAside();
|
||||||
|
} else {
|
||||||
|
if (!pickerOpen() && !metaOpen()) {
|
||||||
|
document.body.classList.remove('drawer-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerOpen() {
|
||||||
|
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaOpen() {
|
||||||
|
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commentsSheetToggle) {
|
||||||
|
commentsSheetToggle.addEventListener('click', () => {
|
||||||
|
setCommentsSheetOpen(!commentsSheetOpen());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commentsSheetClose) {
|
||||||
|
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const body = document.querySelector('.markdown-body');
|
const body = document.querySelector('.markdown-body');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|||||||
@@ -11,15 +11,14 @@
|
|||||||
var conflictApply = document.querySelector("[data-conflict-apply]");
|
var conflictApply = document.querySelector("[data-conflict-apply]");
|
||||||
var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
|
var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
|
||||||
var conflictResolution = document.querySelector("[data-conflict-resolution]");
|
var conflictResolution = document.querySelector("[data-conflict-resolution]");
|
||||||
var conflictResolutionMount = document.querySelector("[data-conflict-monaco-mount]");
|
var conflictResolutionMount = document.querySelector("[data-conflict-codemirror-mount]");
|
||||||
var conflictUseServer = document.querySelector("[data-conflict-use='server']");
|
var conflictUseServer = document.querySelector("[data-conflict-use='server']");
|
||||||
var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
|
var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
|
||||||
var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
|
var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
|
||||||
var monacoContainer = document.querySelector("[data-monaco-mount]");
|
var codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
|
||||||
var monacoLoaded = false;
|
var codeMirrorReadyPromise = null;
|
||||||
var monacoReadyPromise = null;
|
var codeMirrorModules = null;
|
||||||
var monacoDarkTheme = "cairnquire-dark";
|
var documentCompletionOptionsPromise = null;
|
||||||
var monacoLightTheme = "cairnquire-light";
|
|
||||||
|
|
||||||
if (!form || !textarea || statusNodes.length === 0) {
|
if (!form || !textarea || statusNodes.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -36,6 +35,9 @@
|
|||||||
var conflictDiffView = null;
|
var conflictDiffView = null;
|
||||||
var resolutionEditor = null;
|
var resolutionEditor = null;
|
||||||
var suppressResolutionEvents = false;
|
var suppressResolutionEvents = false;
|
||||||
|
var doneLink = document.querySelector("[data-done-link]");
|
||||||
|
var isSaving = false;
|
||||||
|
var suppressBeforeUnload = false;
|
||||||
|
|
||||||
function setStatus(nextStatus) {
|
function setStatus(nextStatus) {
|
||||||
statusNodes.forEach(function (node) {
|
statusNodes.forEach(function (node) {
|
||||||
@@ -223,37 +225,49 @@
|
|||||||
var value = getEditorValue();
|
var value = getEditorValue();
|
||||||
if (value === lastSavedValue) {
|
if (value === lastSavedValue) {
|
||||||
setStatus("Saved");
|
setStatus("Saved");
|
||||||
return;
|
return "unchanged";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
hideConflict();
|
hideConflict();
|
||||||
setStatus("Saving");
|
setStatus("Saving");
|
||||||
|
|
||||||
if (!window.MDHubSync) {
|
if (!window.MDHubSync) {
|
||||||
|
isSaving = false;
|
||||||
setStatus("Queued");
|
setStatus("Queued");
|
||||||
return;
|
return "queued";
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await window.MDHubSync.saveDocument(path, value, baseHash);
|
try {
|
||||||
if (result.status === "saved") {
|
var result = await window.MDHubSync.saveDocument(path, value, baseHash);
|
||||||
lastSavedValue = value;
|
if (result.status === "saved") {
|
||||||
if (hashEl && result.result && result.result.hash) {
|
lastSavedValue = value;
|
||||||
hashEl.textContent = result.result.hash;
|
if (hashEl && result.result && result.result.hash) {
|
||||||
baseHash = result.result.hash;
|
hashEl.textContent = result.result.hash;
|
||||||
if (documentShell) {
|
baseHash = result.result.hash;
|
||||||
documentShell.setAttribute("data-document-hash", result.result.hash);
|
if (documentShell) {
|
||||||
|
documentShell.setAttribute("data-document-hash", result.result.hash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
setStatus("Saved");
|
||||||
|
isSaving = false;
|
||||||
|
return "saved";
|
||||||
}
|
}
|
||||||
setStatus("Saved");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === "conflict") {
|
if (result.status === "conflict") {
|
||||||
showConflict(result.conflict, value);
|
showConflict(result.conflict, value);
|
||||||
return;
|
isSaving = false;
|
||||||
}
|
return "conflict";
|
||||||
|
}
|
||||||
|
|
||||||
setStatus("Queued");
|
setStatus("Queued");
|
||||||
|
return "queued";
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("Queued");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyResolvedConflict() {
|
async function applyResolvedConflict() {
|
||||||
@@ -317,138 +331,339 @@
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMonaco() {
|
function doneHref() {
|
||||||
if (monacoReadyPromise) {
|
return doneLink ? doneLink.getAttribute("href") : "/docs/" + path.replace(/\.md$/, "");
|
||||||
return monacoReadyPromise;
|
}
|
||||||
}
|
|
||||||
if (monacoLoaded && window.monaco) {
|
|
||||||
monacoReadyPromise = Promise.resolve();
|
|
||||||
return monacoReadyPromise;
|
|
||||||
}
|
|
||||||
monacoLoaded = true;
|
|
||||||
|
|
||||||
|
function clearScheduledSave() {
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveEditorNow() {
|
||||||
|
clearScheduledSave();
|
||||||
|
suppressBeforeUnload = true;
|
||||||
|
window.location.href = doneHref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveThenLeaveEditor() {
|
||||||
|
var href = doneHref();
|
||||||
|
|
||||||
|
if (currentConflict) {
|
||||||
|
setStatus("Conflict");
|
||||||
|
if (conflictNotice) {
|
||||||
|
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScheduledSave();
|
||||||
|
|
||||||
|
if (isSaving) {
|
||||||
|
waitForSaveThenNavigate(href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Saving");
|
||||||
|
saveNow()
|
||||||
|
.then(function (result) {
|
||||||
|
if (result === "saved" || result === "unchanged") {
|
||||||
|
suppressBeforeUnload = true;
|
||||||
|
window.location.href = href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === "conflict") {
|
||||||
|
setStatus("Conflict");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Queued");
|
||||||
|
if (window.confirm("Could not save. Leave without saving?")) {
|
||||||
|
suppressBeforeUnload = true;
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
setStatus("Queued");
|
||||||
|
if (window.confirm("Could not save. Leave without saving?")) {
|
||||||
|
suppressBeforeUnload = true;
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCodeMirror() {
|
||||||
|
if (codeMirrorReadyPromise) {
|
||||||
|
return codeMirrorReadyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeMirrorReadyPromise = Promise.all([
|
||||||
|
import("/static/codemirror.bundle.js"),
|
||||||
|
]).then(function (modules) {
|
||||||
|
var bundle = modules[0];
|
||||||
|
codeMirrorModules = {
|
||||||
|
cm: bundle,
|
||||||
|
markdown: bundle,
|
||||||
|
vim: bundle,
|
||||||
|
};
|
||||||
|
registerVimCommands();
|
||||||
|
return codeMirrorModules;
|
||||||
|
});
|
||||||
|
|
||||||
|
return codeMirrorReadyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerVimCommands() {
|
||||||
|
var Vim = codeMirrorModules && codeMirrorModules.vim && codeMirrorModules.vim.Vim;
|
||||||
|
if (!Vim) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vim.defineEx("write", "w", function () {
|
||||||
|
saveNow().catch(function () {
|
||||||
|
setStatus("Queued");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Vim.defineEx("quit", "q", function () {
|
||||||
|
leaveEditorNow();
|
||||||
|
});
|
||||||
|
Vim.defineEx("wq", "wq", function () {
|
||||||
|
saveThenLeaveEditor();
|
||||||
|
});
|
||||||
|
Vim.defineEx("xit", "x", function () {
|
||||||
|
saveThenLeaveEditor();
|
||||||
|
});
|
||||||
|
Vim.map("<Esc>", ":q<CR>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeMirrorTheme() {
|
||||||
|
var EditorView = codeMirrorModules.cm.EditorView;
|
||||||
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
|
||||||
monacoReadyPromise = new Promise(function (resolve) {
|
return EditorView.theme({
|
||||||
var script = document.createElement("script");
|
"&": {
|
||||||
script.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js";
|
height: "100%",
|
||||||
script.onload = function () {
|
minHeight: "60vh",
|
||||||
require.config({
|
backgroundColor: "var(--panel)",
|
||||||
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" },
|
color: "var(--text)",
|
||||||
});
|
fontSize: "14px",
|
||||||
require(["vs/editor/editor.main"], function () {
|
},
|
||||||
monaco.editor.defineTheme(monacoDarkTheme, {
|
"&.cm-focused": {
|
||||||
base: "vs-dark",
|
outline: "none",
|
||||||
inherit: true,
|
},
|
||||||
rules: [
|
".cm-scroller": {
|
||||||
{ token: "comment", foreground: "8B9DAF", fontStyle: "italic" },
|
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
||||||
{ token: "keyword", foreground: "E8943A" },
|
lineHeight: "22px",
|
||||||
{ token: "string", foreground: "C9A96E" },
|
},
|
||||||
{ token: "number", foreground: "C9A96E" },
|
".cm-content": {
|
||||||
{ token: "type", foreground: "E8943A" },
|
padding: "12px 0",
|
||||||
{ token: "tag", foreground: "E8943A" },
|
caretColor: "var(--accent)",
|
||||||
{ token: "attribute.name", foreground: "D4A05A" },
|
},
|
||||||
{ token: "attribute.value", foreground: "C9A96E" },
|
".cm-line": {
|
||||||
{ token: "delimiter", foreground: "B0B8C4" },
|
padding: "0 14px",
|
||||||
{ token: "variable", foreground: "D4D8DE" },
|
},
|
||||||
],
|
".cm-gutters": {
|
||||||
colors: {
|
backgroundColor: "var(--panel-strong)",
|
||||||
"editor.background": "#1E1E1E",
|
borderRight: "1px solid var(--border)",
|
||||||
"editor.foreground": "#E8ECF0",
|
color: "var(--muted)",
|
||||||
"editor.lineHighlightBackground": "#282828",
|
},
|
||||||
"editor.selectionBackground": "#3A3A3A",
|
".cm-activeLine, .cm-activeLineGutter": {
|
||||||
"editorLineNumber.foreground": "#555555",
|
backgroundColor: isDark ? "oklch(0.24 0.012 55)" : "#f0ede5",
|
||||||
"editorLineNumber.activeForeground": "#E8943A",
|
},
|
||||||
"editor.inactiveSelectionBackground": "#333333",
|
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||||
"editorCursor.foreground": "#E8943A",
|
backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent)",
|
||||||
"editorIndentGuide.background": "#2A2A2A",
|
},
|
||||||
"editorIndentGuide.activeBackground": "#3A3A3A",
|
".cm-cursor": {
|
||||||
"editorWhitespace.foreground": "#2A2A2A",
|
borderLeftColor: "var(--accent)",
|
||||||
"editorGutter.background": "#1E1E1E",
|
},
|
||||||
"editorOverviewRuler.border": "#1E1E1E",
|
".cm-panel": {
|
||||||
"scrollbarSlider.background": "#3A3A3A88",
|
backgroundColor: "var(--panel-strong)",
|
||||||
"scrollbarSlider.hoverBackground": "#4A4A4A88",
|
borderTop: "1px solid var(--border)",
|
||||||
"scrollbarSlider.activeBackground": "#5A5A5A88",
|
color: "var(--text)",
|
||||||
"minimap.background": "#1E1E1E",
|
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
||||||
},
|
},
|
||||||
});
|
".cm-vim-panel input": {
|
||||||
|
color: "var(--text)",
|
||||||
|
},
|
||||||
|
}, { dark: isDark });
|
||||||
|
}
|
||||||
|
|
||||||
monaco.editor.defineTheme(monacoLightTheme, {
|
function codeMirrorHighlighting() {
|
||||||
base: "vs",
|
var cm = codeMirrorModules.cm;
|
||||||
inherit: true,
|
var tags = codeMirrorModules.cm.tags;
|
||||||
rules: [
|
var urlColor = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
{ token: "comment", foreground: "6A7B8C", fontStyle: "italic" },
|
? "oklch(0.78 0.14 190)"
|
||||||
{ token: "keyword", foreground: "C67A2A" },
|
: "#047c78";
|
||||||
{ token: "string", foreground: "8A6D3B" },
|
|
||||||
{ token: "number", foreground: "8A6D3B" },
|
|
||||||
{ token: "type", foreground: "C67A2A" },
|
|
||||||
{ token: "tag", foreground: "C67A2A" },
|
|
||||||
{ token: "attribute.name", foreground: "A87D3A" },
|
|
||||||
{ token: "attribute.value", foreground: "8A6D3B" },
|
|
||||||
],
|
|
||||||
colors: {
|
|
||||||
"editor.background": "#FCFAF5",
|
|
||||||
"editor.foreground": "#1C2430",
|
|
||||||
"editor.lineHighlightBackground": "#F0EDE5",
|
|
||||||
"editor.selectionBackground": "#E8943A33",
|
|
||||||
"editorLineNumber.foreground": "#AAAAAA",
|
|
||||||
"editorLineNumber.activeForeground": "#C67A2A",
|
|
||||||
"editorCursor.foreground": "#C67A2A",
|
|
||||||
"editorIndentGuide.background": "#E8E5DD",
|
|
||||||
"editorGutter.background": "#FCFAF5",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (monacoContainer) {
|
return cm.syntaxHighlighting(cm.HighlightStyle.define([
|
||||||
editor = monaco.editor.create(monacoContainer, {
|
{
|
||||||
value: textarea.value,
|
tag: [tags.url, tags.link],
|
||||||
language: "markdown",
|
color: urlColor,
|
||||||
theme: isDark ? monacoDarkTheme : monacoLightTheme,
|
textDecoration: "underline",
|
||||||
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
textUnderlineOffset: "2px",
|
||||||
fontSize: 14,
|
},
|
||||||
lineHeight: 22,
|
]));
|
||||||
minimap: { enabled: false },
|
}
|
||||||
wordWrap: "on",
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
padding: { top: 12 },
|
|
||||||
renderLineHighlight: "line",
|
|
||||||
smoothScrolling: true,
|
|
||||||
cursorBlinking: "smooth",
|
|
||||||
cursorSmoothCaretAnimation: "on",
|
|
||||||
bracketPairColorization: { enabled: true },
|
|
||||||
automaticLayout: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
textarea.hidden = true;
|
function normalizeCompletionPath(doc) {
|
||||||
monacoContainer.classList.add("monaco-mounted");
|
var docPath = doc && doc.path ? String(doc.path) : "";
|
||||||
|
return docPath.replace(/\.md$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
editor.onDidChangeModelContent(function () {
|
function buildDocumentCompletionOptions(documents) {
|
||||||
textarea.value = editor.getValue();
|
var seen = new Set();
|
||||||
scheduleSave();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (window.MDHubSync) {
|
return (documents || [])
|
||||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
|
.map(function (doc) {
|
||||||
saveNow().catch(function () {
|
var label = normalizeCompletionPath(doc);
|
||||||
setStatus("Queued");
|
if (!label || seen.has(label) || label === path.replace(/\.md$/, "")) {
|
||||||
});
|
return null;
|
||||||
});
|
}
|
||||||
|
seen.add(label);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label,
|
||||||
|
detail: doc.title && doc.title !== label ? doc.title : doc.path,
|
||||||
|
type: "file",
|
||||||
|
apply: function (view, completion, from, to) {
|
||||||
|
var insert = completion.label;
|
||||||
|
var after = view.state.doc.sliceString(to, Math.min(view.state.doc.length, to + 2));
|
||||||
|
if (after !== "]]") {
|
||||||
|
insert += "]]";
|
||||||
}
|
}
|
||||||
|
view.dispatch({
|
||||||
var darkMQ = window.matchMedia("(prefers-color-scheme: dark)");
|
changes: { from: from, to: to, insert: insert },
|
||||||
darkMQ.addEventListener("change", function (e) {
|
selection: { anchor: from + insert.length },
|
||||||
monaco.editor.setTheme(e.matches ? monacoDarkTheme : monacoLightTheme);
|
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort(function (a, b) {
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
editor.focus();
|
function fetchDocumentCompletionOptions() {
|
||||||
}
|
return fetch("/api/documents", { credentials: "same-origin" })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Could not load documents");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
var documents = data.documents || [];
|
||||||
|
if (window.MDHubCache && window.MDHubCache.cacheDocuments) {
|
||||||
|
window.MDHubCache.cacheDocuments(documents).catch(function () {});
|
||||||
|
}
|
||||||
|
return buildDocumentCompletionOptions(documents);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
resolve();
|
function loadDocumentCompletionOptions() {
|
||||||
});
|
if (documentCompletionOptionsPromise) {
|
||||||
|
return documentCompletionOptionsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
documentCompletionOptionsPromise = Promise.resolve()
|
||||||
|
.then(function () {
|
||||||
|
if (window.MDHubCache && window.MDHubCache.getCachedDocuments) {
|
||||||
|
return window.MDHubCache.getCachedDocuments().then(function (documents) {
|
||||||
|
if (documents && documents.length > 0) {
|
||||||
|
return buildDocumentCompletionOptions(documents);
|
||||||
|
}
|
||||||
|
return fetchDocumentCompletionOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fetchDocumentCompletionOptions();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return documentCompletionOptionsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikiLinkCompletions(context) {
|
||||||
|
var before = context.matchBefore(/\[\[([^\]\n]*)$/);
|
||||||
|
if (!before) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadDocumentCompletionOptions().then(function (options) {
|
||||||
|
if (!options.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: before.from + 2,
|
||||||
|
to: before.to,
|
||||||
|
options: options,
|
||||||
|
validFor: /^[^\]\n]*$/,
|
||||||
};
|
};
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
});
|
||||||
return monacoReadyPromise;
|
}
|
||||||
|
|
||||||
|
function createCodeMirrorEditor(mount, initialValue, options) {
|
||||||
|
var cm = codeMirrorModules.cm;
|
||||||
|
var markdownModule = codeMirrorModules.markdown;
|
||||||
|
var vimModule = codeMirrorModules.vim;
|
||||||
|
var onChange = options && options.onChange ? options.onChange : function () {};
|
||||||
|
var extensions = [
|
||||||
|
vimModule.vim({ status: true }),
|
||||||
|
cm.basicSetup,
|
||||||
|
markdownModule.markdown({
|
||||||
|
base: markdownModule.markdownLanguage,
|
||||||
|
}),
|
||||||
|
cm.EditorView.lineWrapping,
|
||||||
|
codeMirrorTheme(),
|
||||||
|
codeMirrorHighlighting(),
|
||||||
|
cm.autocompletion({
|
||||||
|
override: [wikiLinkCompletions],
|
||||||
|
activateOnTyping: true,
|
||||||
|
}),
|
||||||
|
cm.EditorView.updateListener.of(function (update) {
|
||||||
|
if (update.docChanged) {
|
||||||
|
onChange(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
mount.replaceChildren();
|
||||||
|
mount.classList.add("codemirror-mounted");
|
||||||
|
|
||||||
|
var view = new cm.EditorView({
|
||||||
|
doc: initialValue,
|
||||||
|
extensions: extensions,
|
||||||
|
parent: mount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: view,
|
||||||
|
getValue: function () {
|
||||||
|
return view.state.doc.toString();
|
||||||
|
},
|
||||||
|
setValue: function (value) {
|
||||||
|
if (view.state.doc.toString() === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
focus: function () {
|
||||||
|
view.focus();
|
||||||
|
},
|
||||||
|
hasTextFocus: function () {
|
||||||
|
return view.hasFocus;
|
||||||
|
},
|
||||||
|
destroy: function () {
|
||||||
|
view.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureResolutionEditor() {
|
function ensureResolutionEditor() {
|
||||||
@@ -456,54 +671,42 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMonaco().then(function () {
|
loadCodeMirror().then(function () {
|
||||||
if (!window.monaco || resolutionEditor) {
|
if (resolutionEditor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
resolutionEditor = createCodeMirrorEditor(conflictResolutionMount, conflictResolution.value || resolvedConflictContent, {
|
||||||
resolutionEditor = monaco.editor.create(conflictResolutionMount, {
|
onChange: function (value) {
|
||||||
value: conflictResolution.value || resolvedConflictContent,
|
if (suppressResolutionEvents) {
|
||||||
language: "markdown",
|
return;
|
||||||
theme: isDark ? monacoDarkTheme : monacoLightTheme,
|
}
|
||||||
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
resolvedConflictContent = value;
|
||||||
fontSize: 14,
|
conflictResolution.value = value;
|
||||||
lineHeight: 22,
|
if (conflictApply) {
|
||||||
minimap: { enabled: false },
|
conflictApply.disabled = false;
|
||||||
wordWrap: "on",
|
}
|
||||||
scrollBeyondLastLine: false,
|
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
|
||||||
padding: { top: 12 },
|
},
|
||||||
renderLineHighlight: "line",
|
|
||||||
smoothScrolling: true,
|
|
||||||
cursorBlinking: "smooth",
|
|
||||||
cursorSmoothCaretAnimation: "on",
|
|
||||||
bracketPairColorization: { enabled: true },
|
|
||||||
automaticLayout: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
conflictResolution.hidden = true;
|
conflictResolution.hidden = true;
|
||||||
conflictResolutionMount.classList.add("monaco-mounted");
|
|
||||||
|
|
||||||
resolutionEditor.onDidChangeModelContent(function () {
|
|
||||||
if (suppressResolutionEvents) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolvedConflictContent = resolutionEditor.getValue();
|
|
||||||
conflictResolution.value = resolvedConflictContent;
|
|
||||||
if (conflictApply) {
|
|
||||||
conflictApply.disabled = false;
|
|
||||||
}
|
|
||||||
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
|
|
||||||
});
|
|
||||||
|
|
||||||
resolutionEditor.focus();
|
resolutionEditor.focus();
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
conflictResolution.hidden = false;
|
conflictResolution.hidden = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (monacoContainer) {
|
if (codeMirrorContainer) {
|
||||||
loadMonaco().catch(function () {
|
loadCodeMirror().then(function () {
|
||||||
|
editor = createCodeMirrorEditor(codeMirrorContainer, textarea.value, {
|
||||||
|
onChange: function (value) {
|
||||||
|
textarea.value = value;
|
||||||
|
scheduleSave();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
textarea.hidden = true;
|
||||||
|
editor.focus();
|
||||||
|
}).catch(function () {
|
||||||
textarea.addEventListener("input", scheduleSave);
|
textarea.addEventListener("input", scheduleSave);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -571,9 +774,72 @@
|
|||||||
|
|
||||||
restorePendingConflict();
|
restorePendingConflict();
|
||||||
|
|
||||||
|
if (doneLink) {
|
||||||
|
doneLink.addEventListener("click", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
saveThenLeaveEditor();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForSaveThenNavigate(href) {
|
||||||
|
var checkInterval = setInterval(function () {
|
||||||
|
if (!isSaving) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
if (currentConflict) {
|
||||||
|
setStatus("Conflict");
|
||||||
|
if (conflictNotice) {
|
||||||
|
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressBeforeUnload = true;
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
// Timeout after 10 seconds
|
||||||
|
setTimeout(function () {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn on browser unload if there are unsaved changes
|
||||||
|
window.addEventListener("beforeunload", function (e) {
|
||||||
|
if (suppressBeforeUnload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getEditorValue() !== lastSavedValue || currentConflict) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function editorSurfaceHasFocus(target) {
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target === textarea || target.closest("[data-codemirror-mount], [data-conflict-codemirror-mount], [data-document-editor], .cm-editor")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(editor && editor.hasTextFocus && editor.hasTextFocus());
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", function (e) {
|
document.addEventListener("keydown", function (e) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
saveNow().catch(function () {
|
||||||
|
setStatus("Queued");
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if (e.metaKey && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
saveThenLeaveEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editorSurfaceHasFocus(e.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
})();
|
})();
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.6 KiB |
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal file
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
if (document.querySelector("[data-editor-form]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var browser = document.querySelector(".miller-browser");
|
||||||
|
if (!browser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var documentShell = document.querySelector(".document-shell[data-document-path]");
|
||||||
|
var markdownBody = document.querySelector(".markdown-body");
|
||||||
|
var mode = "browser";
|
||||||
|
var selectedColumnIndex = 0;
|
||||||
|
var selectedLink = null;
|
||||||
|
var selectedParagraphIndex = -1;
|
||||||
|
|
||||||
|
function getColumns() {
|
||||||
|
return Array.prototype.slice.call(browser.querySelectorAll(".miller-column"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnLinks(columnIndex) {
|
||||||
|
var columns = getColumns();
|
||||||
|
if (!columns[columnIndex]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.prototype.slice.call(columns[columnIndex].querySelectorAll("a[href]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParagraphTargets() {
|
||||||
|
if (!markdownBody) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.prototype.slice.call(
|
||||||
|
markdownBody.querySelectorAll(".commentable, p, h1, h2, h3, h4, h5, h6, li, blockquote, pre")
|
||||||
|
).filter(function (el) {
|
||||||
|
return (el.textContent || "").trim().length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target) {
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(target.closest(
|
||||||
|
"input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox'], .monaco-editor"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyboardNavigationTarget(target) {
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(target.closest(".miller-browser a[href], .markdown-body .is-keyboard-selected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreTarget(target) {
|
||||||
|
if (isEditableTarget(target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isKeyboardNavigationTarget(target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(target && target.closest("a[href], button, summary, details, [role='button'], [role='menuitem']"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showKeyboardUI() {
|
||||||
|
document.body.classList.add("keyboard-navigation-active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBrowserSelection() {
|
||||||
|
browser.querySelectorAll(".is-keyboard-selected").forEach(function (node) {
|
||||||
|
node.classList.remove("is-keyboard-selected");
|
||||||
|
node.removeAttribute("aria-selected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearParagraphSelection() {
|
||||||
|
getParagraphTargets().forEach(function (node) {
|
||||||
|
node.classList.remove("is-keyboard-selected");
|
||||||
|
node.removeAttribute("tabindex");
|
||||||
|
node.removeAttribute("aria-selected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollBrowserColumnIntoView(column) {
|
||||||
|
if (!column) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var leftEdge = column.offsetLeft;
|
||||||
|
var rightEdge = leftEdge + column.offsetWidth;
|
||||||
|
var targetLeft = browser.scrollLeft;
|
||||||
|
|
||||||
|
if (leftEdge < browser.scrollLeft) {
|
||||||
|
targetLeft = leftEdge - 8;
|
||||||
|
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
|
||||||
|
targetLeft = rightEdge - browser.clientWidth + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLeft !== browser.scrollLeft) {
|
||||||
|
browser.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandColumnForKeyboard(column) {
|
||||||
|
getColumns().forEach(function (candidate) {
|
||||||
|
if (candidate !== column) {
|
||||||
|
candidate.classList.remove("is-preview-expanded");
|
||||||
|
candidate.style.removeProperty("--expanded-column-width");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!column || !column.matches(".miller-column:not(:first-child):not(:last-child)")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
|
||||||
|
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
|
||||||
|
column.classList.add("is-preview-expanded");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBrowserSelection(focusSelected) {
|
||||||
|
clearBrowserSelection();
|
||||||
|
var columns = getColumns();
|
||||||
|
var column = columns[selectedColumnIndex];
|
||||||
|
if (!column) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandColumnForKeyboard(column);
|
||||||
|
scrollBrowserColumnIntoView(column);
|
||||||
|
|
||||||
|
if (!selectedLink || !column.contains(selectedLink)) {
|
||||||
|
selectedLink = column.querySelector("a.is-active[href]") || column.querySelector("a[href]");
|
||||||
|
}
|
||||||
|
if (!selectedLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLink.classList.add("is-keyboard-selected");
|
||||||
|
selectedLink.setAttribute("aria-selected", "true");
|
||||||
|
if (focusSelected) {
|
||||||
|
selectedLink.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBrowserColumn(columnIndex, focusSelected) {
|
||||||
|
var columns = getColumns();
|
||||||
|
if (!columns.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = "browser";
|
||||||
|
selectedColumnIndex = clamp(columnIndex, 0, columns.length - 1);
|
||||||
|
var links = getColumnLinks(selectedColumnIndex);
|
||||||
|
selectedLink = links.find(function (link) {
|
||||||
|
return link.classList.contains("is-active");
|
||||||
|
}) || links[0] || null;
|
||||||
|
clearParagraphSelection();
|
||||||
|
updateBrowserSelection(focusSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveBrowserVertical(delta) {
|
||||||
|
var links = getColumnLinks(selectedColumnIndex);
|
||||||
|
if (!links.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentIndex = links.indexOf(selectedLink);
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
currentIndex = links.findIndex(function (link) {
|
||||||
|
return link.classList.contains("is-active");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLink = links[clamp(currentIndex + delta, 0, links.length - 1)];
|
||||||
|
updateBrowserSelection(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(link) {
|
||||||
|
if (!link || !link.href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = link.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveBrowserRight() {
|
||||||
|
var columns = getColumns();
|
||||||
|
if (!columns.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedColumnIndex < columns.length - 1 && selectedLink && selectedLink.classList.contains("is-active")) {
|
||||||
|
selectBrowserColumn(selectedColumnIndex + 1, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedColumnIndex < columns.length - 1 && !selectedLink) {
|
||||||
|
selectBrowserColumn(selectedColumnIndex + 1, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedLink && !selectedLink.classList.contains("is-active")) {
|
||||||
|
navigateTo(selectedLink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markdownBody && getParagraphTargets().length) {
|
||||||
|
enterParagraphMode(0);
|
||||||
|
} else if (selectedLink) {
|
||||||
|
navigateTo(selectedLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestParagraphIndex() {
|
||||||
|
var targets = getParagraphTargets();
|
||||||
|
if (!targets.length) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (selectedParagraphIndex >= 0 && selectedParagraphIndex < targets.length) {
|
||||||
|
return selectedParagraphIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shellRect = documentShell ? documentShell.getBoundingClientRect() : null;
|
||||||
|
var targetY = shellRect ? shellRect.top + shellRect.height * 0.35 : window.innerHeight * 0.35;
|
||||||
|
var bestIndex = 0;
|
||||||
|
var bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
targets.forEach(function (el, index) {
|
||||||
|
var distance = Math.abs(el.getBoundingClientRect().top - targetY);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
bestIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return bestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectParagraph(index) {
|
||||||
|
var targets = getParagraphTargets();
|
||||||
|
if (!targets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBrowserSelection();
|
||||||
|
clearParagraphSelection();
|
||||||
|
mode = "paragraph";
|
||||||
|
selectedParagraphIndex = clamp(index, 0, targets.length - 1);
|
||||||
|
|
||||||
|
var target = targets[selectedParagraphIndex];
|
||||||
|
target.classList.add("is-keyboard-selected");
|
||||||
|
target.setAttribute("aria-selected", "true");
|
||||||
|
target.setAttribute("tabindex", "-1");
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterParagraphMode(offset) {
|
||||||
|
var index = nearestParagraphIndex();
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectParagraph(index + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveParagraphVertical(delta) {
|
||||||
|
var targets = getParagraphTargets();
|
||||||
|
if (!targets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var currentIndex = selectedParagraphIndex;
|
||||||
|
if (currentIndex < 0 || currentIndex >= targets.length) {
|
||||||
|
currentIndex = nearestParagraphIndex();
|
||||||
|
}
|
||||||
|
selectParagraph(currentIndex + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSelectedParagraphInEditor() {
|
||||||
|
var editLink = document.querySelector(".document-actions-dropdown__item[href$='/edit']");
|
||||||
|
if (editLink) {
|
||||||
|
navigateTo(editLink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var path = documentShell ? documentShell.getAttribute("data-document-path") : "";
|
||||||
|
if (!path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = "/docs/" + path.replace(/\.md$/, "") + "/edit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSearch() {
|
||||||
|
var search = document.querySelector(".site-search input[type='search'], .site-search input[name='q']");
|
||||||
|
if (!search) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearBrowserSelection();
|
||||||
|
clearParagraphSelection();
|
||||||
|
search.focus();
|
||||||
|
search.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusCommentsSidebar() {
|
||||||
|
var desktopToggle = document.querySelector("[data-comment-toggle]");
|
||||||
|
if (desktopToggle && desktopToggle.getAttribute("aria-pressed") === "false") {
|
||||||
|
desktopToggle.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
var mobileToggle = document.querySelector("[data-toggle-comments]");
|
||||||
|
if (mobileToggle && window.matchMedia("(max-width: 760px)").matches && mobileToggle.getAttribute("aria-expanded") === "false") {
|
||||||
|
mobileToggle.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
var focusTarget = document.querySelector("[data-comments-aside-list] .comments-aside-item") ||
|
||||||
|
document.querySelector("[data-comments-aside-list]") ||
|
||||||
|
document.querySelector("[data-comments-aside]");
|
||||||
|
if (!focusTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBrowserSelection();
|
||||||
|
clearParagraphSelection();
|
||||||
|
focusTarget.setAttribute("tabindex", "-1");
|
||||||
|
focusTarget.focus({ preventScroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusDocumentActions() {
|
||||||
|
var dropdown = document.querySelector(".document-actions-dropdown");
|
||||||
|
if (!dropdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.open = true;
|
||||||
|
var focusTarget = dropdown.querySelector(".document-actions-dropdown__item[href], .document-actions-dropdown__item:not([disabled])") ||
|
||||||
|
dropdown.querySelector("summary");
|
||||||
|
if (!focusTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBrowserSelection();
|
||||||
|
clearParagraphSelection();
|
||||||
|
focusTarget.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDocumentActions() {
|
||||||
|
var dropdown = document.querySelector(".document-actions-dropdown[open]");
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.open = false;
|
||||||
|
var summary = dropdown.querySelector("summary");
|
||||||
|
if (summary) {
|
||||||
|
summary.focus();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnter() {
|
||||||
|
if (mode === "paragraph") {
|
||||||
|
openSelectedParagraphInEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateTo(selectedLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || shouldIgnoreTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = event.key;
|
||||||
|
var normalized = key.length === 1 ? key.toLowerCase() : key;
|
||||||
|
|
||||||
|
if (event.code === "Backslash" || key === "\\") {
|
||||||
|
event.preventDefault();
|
||||||
|
focusSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "c") {
|
||||||
|
event.preventDefault();
|
||||||
|
focusCommentsSidebar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "e") {
|
||||||
|
event.preventDefault();
|
||||||
|
focusDocumentActions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Escape" && closeDocumentActions()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "ArrowUp" || normalized === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
showKeyboardUI();
|
||||||
|
if (mode === "paragraph") {
|
||||||
|
moveParagraphVertical(-1);
|
||||||
|
} else {
|
||||||
|
moveBrowserVertical(-1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "ArrowDown" || normalized === "j") {
|
||||||
|
event.preventDefault();
|
||||||
|
showKeyboardUI();
|
||||||
|
if (mode === "paragraph") {
|
||||||
|
moveParagraphVertical(1);
|
||||||
|
} else {
|
||||||
|
moveBrowserVertical(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "ArrowLeft" || normalized === "h") {
|
||||||
|
event.preventDefault();
|
||||||
|
showKeyboardUI();
|
||||||
|
if (mode === "paragraph") {
|
||||||
|
selectBrowserColumn(getColumns().length - 1, true);
|
||||||
|
} else {
|
||||||
|
selectBrowserColumn(selectedColumnIndex - 1, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "ArrowRight" || normalized === "l") {
|
||||||
|
event.preventDefault();
|
||||||
|
showKeyboardUI();
|
||||||
|
if (mode === "paragraph") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveBrowserRight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
showKeyboardUI();
|
||||||
|
handleEnter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSelection() {
|
||||||
|
var columns = getColumns();
|
||||||
|
if (!columns.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeLinks = Array.prototype.slice.call(browser.querySelectorAll("a.is-active[href]"));
|
||||||
|
var activeLink = activeLinks[activeLinks.length - 1] || browser.querySelector("a[href]");
|
||||||
|
selectedLink = activeLink;
|
||||||
|
if (activeLink) {
|
||||||
|
var activeColumn = activeLink.closest(".miller-column");
|
||||||
|
selectedColumnIndex = Math.max(0, columns.indexOf(activeColumn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initSelection();
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
})();
|
||||||
86
apps/server/internal/httpserver/static/mobile.js
Normal file
86
apps/server/internal/httpserver/static/mobile.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const backdrop = document.querySelector('[data-mobile-drawer-backdrop]');
|
||||||
|
const pickerDrawer = document.querySelector('[data-picker-drawer]');
|
||||||
|
const metaDrawer = document.querySelector('[data-meta-drawer]');
|
||||||
|
const commentsAside = document.querySelector('[data-comments-aside]');
|
||||||
|
const pickerToggle = document.querySelector('[data-toggle-picker]');
|
||||||
|
const metaToggle = document.querySelector('[data-toggle-meta]');
|
||||||
|
const pickerClose = document.querySelector('[data-close-picker]');
|
||||||
|
const metaClose = document.querySelector('[data-close-meta]');
|
||||||
|
|
||||||
|
function anyDrawerOpen() {
|
||||||
|
return Boolean(
|
||||||
|
pickerDrawer?.classList.contains('is-open') ||
|
||||||
|
metaDrawer?.classList.contains('is-open') ||
|
||||||
|
commentsAside?.classList.contains('is-open')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBackdrop() {
|
||||||
|
document.body.classList.toggle('drawer-open', anyDrawerOpen());
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerOpen(open) {
|
||||||
|
if (!pickerDrawer) return;
|
||||||
|
pickerDrawer.classList.toggle('is-open', open);
|
||||||
|
if (pickerToggle) pickerToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
updateBackdrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMetaOpen(open) {
|
||||||
|
if (!metaDrawer) return;
|
||||||
|
metaDrawer.classList.toggle('is-open', open);
|
||||||
|
if (metaToggle) metaToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
updateBackdrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllDrawers() {
|
||||||
|
setPickerOpen(false);
|
||||||
|
setMetaOpen(false);
|
||||||
|
if (commentsAside) {
|
||||||
|
commentsAside.classList.remove('is-open');
|
||||||
|
const toggle = document.querySelector('[data-toggle-comments]');
|
||||||
|
if (toggle) toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
updateBackdrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pickerToggle) {
|
||||||
|
pickerToggle.addEventListener('click', () => {
|
||||||
|
const willOpen = !pickerDrawer?.classList.contains('is-open');
|
||||||
|
closeAllDrawers();
|
||||||
|
setPickerOpen(willOpen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metaToggle) {
|
||||||
|
metaToggle.addEventListener('click', () => {
|
||||||
|
const willOpen = !metaDrawer?.classList.contains('is-open');
|
||||||
|
closeAllDrawers();
|
||||||
|
setMetaOpen(willOpen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pickerClose) pickerClose.addEventListener('click', () => setPickerOpen(false));
|
||||||
|
if (metaClose) metaClose.addEventListener('click', () => setMetaOpen(false));
|
||||||
|
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.addEventListener('click', closeAllDrawers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drawers on Escape key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeAllDrawers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close action dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
document.querySelectorAll('.document-actions-dropdown[open]').forEach((details) => {
|
||||||
|
if (!details.contains(e.target)) {
|
||||||
|
details.removeAttribute('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1540,11 +1540,17 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.miller-column a:hover,
|
.miller-column a:hover,
|
||||||
.miller-column a.is-active {
|
.miller-column a.is-active,
|
||||||
|
.keyboard-navigation-active .miller-column a.is-keyboard-selected {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.keyboard-navigation-active .miller-column a.is-keyboard-selected {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.browser-item-name {
|
.browser-item-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1675,46 +1681,6 @@ code {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-meta-panel {
|
|
||||||
margin-top: 0.9rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta-panel summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.7rem 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
color: var(--muted);
|
|
||||||
font: 700 0.78rem/1.2 ui-monospace, SFMono-Regular, monospace;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta-panel summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta-panel summary::after {
|
|
||||||
content: "+";
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1rem;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta-panel[open] summary::after {
|
|
||||||
content: "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta-panel .meta-grid {
|
|
||||||
padding: 0 0.85rem 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-grid {
|
.meta-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
@@ -1959,47 +1925,150 @@ code {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-actions {
|
/* Document actions dropdown */
|
||||||
display: flex;
|
.document-actions-dropdown {
|
||||||
flex-wrap: wrap;
|
position: relative;
|
||||||
gap: 0.5rem;
|
display: inline-block;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-edit-link {
|
.document-actions-dropdown > summary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 2.4rem;
|
width: 2.25rem;
|
||||||
padding: 0 0.9rem;
|
height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown > summary:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, var(--panel-strong));
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown > summary svg {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 14rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
box-shadow: 0 8px 32px rgba(24, 32, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown:not([open]) .document-actions-dropdown__menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-decoration: none;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-edit-link:hover {
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-archive-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 2.4rem;
|
|
||||||
padding: 0 0.9rem;
|
|
||||||
border: 1px solid color-mix(in srgb, #b42318 40%, var(--border));
|
|
||||||
background: transparent;
|
|
||||||
color: #b42318;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-archive-btn:hover {
|
.document-actions-dropdown__item:hover {
|
||||||
background: color-mix(in srgb, #b42318 8%, transparent);
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__item svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__item--danger {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__item--danger:hover {
|
||||||
|
background: color-mix(in srgb, #b42318 10%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__divider {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__meta {
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__meta-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 3rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__meta-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__tag-list a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions-dropdown__tag-list a:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-shell--editor {
|
.document-shell--editor {
|
||||||
@@ -2017,7 +2086,7 @@ code {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-monaco-mount] {
|
[data-codemirror-mount] {
|
||||||
display: none;
|
display: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
@@ -2026,7 +2095,7 @@ code {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-monaco-mount].monaco-mounted {
|
[data-codemirror-mount].codemirror-mounted {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2042,26 +2111,31 @@ code {
|
|||||||
font: 400 0.98rem/1.6 ui-monospace, SFMono-Regular, monospace;
|
font: 400 0.98rem/1.6 ui-monospace, SFMono-Regular, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-monaco-mount].monaco-mounted ~ textarea {
|
[data-codemirror-mount].codemirror-mounted ~ textarea {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes monaco-shimmer {
|
@keyframes codemirror-shimmer {
|
||||||
0% { background-position: -200% 0; }
|
0% { background-position: -200% 0; }
|
||||||
100% { background-position: 200% 0; }
|
100% { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-monaco-mount]:not(.monaco-mounted) {
|
[data-codemirror-mount]:not(.codemirror-mounted) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(90deg, var(--panel-strong) 25%, var(--panel) 50%, var(--panel-strong) 75%);
|
background: linear-gradient(90deg, var(--panel-strong) 25%, var(--panel) 50%, var(--panel-strong) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: monaco-shimmer 1.5s ease infinite;
|
animation: codemirror-shimmer 1.5s ease infinite;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-codemirror-mount] .cm-editor,
|
||||||
|
[data-conflict-codemirror-mount] .cm-editor {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-status {
|
.editor-status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 5.5rem;
|
min-width: 5.5rem;
|
||||||
@@ -2227,7 +2301,7 @@ code {
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-conflict-monaco-mount] {
|
[data-conflict-codemirror-mount] {
|
||||||
display: none;
|
display: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
@@ -2237,11 +2311,11 @@ code {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-conflict-monaco-mount].monaco-mounted {
|
[data-conflict-codemirror-mount].codemirror-mounted {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-conflict-monaco-mount].monaco-mounted + textarea {
|
[data-conflict-codemirror-mount].codemirror-mounted + textarea {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2517,45 +2591,32 @@ code {
|
|||||||
|
|
||||||
.document-meta__header {
|
.document-meta__header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-actions {
|
.document-actions-dropdown__menu {
|
||||||
width: 100%;
|
position: static;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-actions .document-edit-link,
|
.document-actions-dropdown__item {
|
||||||
.document-actions .document-archive-btn {
|
padding: 0.65rem 0.8rem;
|
||||||
flex: 1 1 auto;
|
}
|
||||||
justify-content: center;
|
|
||||||
min-height: 2.2rem;
|
.document-actions-dropdown__meta {
|
||||||
font-size: 0.9rem;
|
padding: 0.5rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-grid {
|
.meta-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-meta-panel summary,
|
|
||||||
.document-meta-panel summary::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta-panel {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta-panel .meta-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Document body padding */
|
/* Document body padding */
|
||||||
.document-shell .markdown-body {
|
.document-shell .markdown-body {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -2726,10 +2787,6 @@ code {
|
|||||||
background: oklch(0.705 0.165 55 / 0.07);
|
background: oklch(0.705 0.165 55 / 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-meta-panel {
|
|
||||||
background: oklch(0.20 0.015 55 / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-form textarea {
|
.editor-form textarea {
|
||||||
background: oklch(0.15 0.012 55);
|
background: oklch(0.15 0.012 55);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -2787,7 +2844,7 @@ code {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-conflict-monaco-mount] {
|
[data-conflict-codemirror-mount] {
|
||||||
border-color: oklch(0.55 0.18 25 / 0.3);
|
border-color: oklch(0.55 0.18 25 / 0.3);
|
||||||
background: oklch(0.14 0.012 55);
|
background: oklch(0.14 0.012 55);
|
||||||
}
|
}
|
||||||
@@ -2867,10 +2924,17 @@ code {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentable:hover {
|
.commentable:hover,
|
||||||
|
.keyboard-navigation-active .commentable.is-keyboard-selected,
|
||||||
|
.keyboard-navigation-active .markdown-body .is-keyboard-selected {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.keyboard-navigation-active .markdown-body .is-keyboard-selected {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 68%, transparent);
|
||||||
|
outline-offset: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-anchor-btn {
|
.comment-anchor-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -1.5rem;
|
right: -1.5rem;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const STATIC_CACHE = "md-hub-static-v2";
|
const STATIC_CACHE = "md-hub-static-v8";
|
||||||
const DB_NAME = "md-hub-cache";
|
const DB_NAME = "md-hub-cache";
|
||||||
const DB_VERSION = 3;
|
const DB_VERSION = 3;
|
||||||
const STORE_PAGES = "pages";
|
const STORE_PAGES = "pages";
|
||||||
@@ -8,7 +8,9 @@ const STATIC_ASSETS = [
|
|||||||
"/static/sync.js",
|
"/static/sync.js",
|
||||||
"/static/realtime.js",
|
"/static/realtime.js",
|
||||||
"/static/editor.js",
|
"/static/editor.js",
|
||||||
|
"/static/codemirror.bundle.js",
|
||||||
"/static/render.js",
|
"/static/render.js",
|
||||||
|
"/static/keyboard-nav.js",
|
||||||
"/static/tags.js",
|
"/static/tags.js",
|
||||||
"/static/favicon.png",
|
"/static/favicon.png",
|
||||||
"/static/cairnquire%20logo%402x.webp",
|
"/static/cairnquire%20logo%402x.webp",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -172,6 +173,9 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P
|
|||||||
}
|
}
|
||||||
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path)
|
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return s.canReadNewDocument(r, file.Path)
|
||||||
|
}
|
||||||
accessErr = err
|
accessErr = err
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="account-sidebar">
|
<aside class="account-sidebar">
|
||||||
|
<section class="account-section">
|
||||||
|
<header class="account-section__header">
|
||||||
|
<div>
|
||||||
|
<p class="account-section__kicker">Security</p>
|
||||||
|
<h2>Passkeys</h2>
|
||||||
|
</div>
|
||||||
|
<p>Add a passkey to sign in with this device, a password manager, or a hardware security key.</p>
|
||||||
|
</header>
|
||||||
|
<form class="auth-form" data-passkey-add>
|
||||||
|
<button type="submit">Add passkey</button>
|
||||||
|
<p class="form-status" data-passkey-add-status></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="account-section">
|
<section class="account-section">
|
||||||
<header class="account-section__header">
|
<header class="account-section__header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -111,6 +111,8 @@
|
|||||||
<script src="/static/auth.js" defer></script>
|
<script src="/static/auth.js" defer></script>
|
||||||
<script src="/static/render.js" defer></script>
|
<script src="/static/render.js" defer></script>
|
||||||
<script src="/static/comments.js" defer></script>
|
<script src="/static/comments.js" defer></script>
|
||||||
|
<script src="/static/keyboard-nav.js" defer></script>
|
||||||
|
<script src="/static/mobile.js" defer></script>
|
||||||
<script src="/static/archive.js" defer></script>
|
<script src="/static/archive.js" defer></script>
|
||||||
<script src="/static/permissions.js" defer></script>
|
<script src="/static/permissions.js" defer></script>
|
||||||
<script src="/static/tags.js" defer></script>
|
<script src="/static/tags.js" defer></script>
|
||||||
|
|||||||
@@ -34,43 +34,55 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="document-meta__header">
|
<div class="document-meta__header">
|
||||||
<h1>{{ .Title }}</h1>
|
<h1>{{ .Title }}</h1>
|
||||||
<div class="document-actions">
|
<details class="document-actions-dropdown">
|
||||||
{{ if .CanWrite }}
|
<summary aria-haspopup="true" aria-label="Document actions">
|
||||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
{{ if .CanAdmin }}
|
<circle cx="12" cy="5" r="1"></circle>
|
||||||
<a class="document-edit-link" href="/permissions?resourceType=document&resourceId={{ .Path }}">Permissions</a>
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="12" cy="19" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div class="document-actions-dropdown__menu">
|
||||||
|
{{ if .CanWrite }}
|
||||||
|
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||||
|
<span>Edit</span>
|
||||||
|
</a>
|
||||||
|
{{ if .CanAdmin }}
|
||||||
|
<a class="document-actions-dropdown__item" href="/permissions?resourceType=document&resourceId={{ .Path }}">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||||||
|
<span>Permissions</span>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
<button class="document-actions-dropdown__item document-actions-dropdown__item--danger" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
|
||||||
|
<span>Archive</span>
|
||||||
|
</button>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
<hr class="document-actions-dropdown__divider">
|
||||||
{{ end }}
|
<div class="document-actions-dropdown__meta">
|
||||||
</div>
|
<div class="document-actions-dropdown__meta-row">
|
||||||
</div>
|
<span class="document-actions-dropdown__meta-label">File</span>
|
||||||
<details class="document-meta-panel">
|
<code class="document-actions-dropdown__meta-value">{{ .Path }}</code>
|
||||||
<summary>
|
</div>
|
||||||
<span>Document details</span>
|
<div class="document-actions-dropdown__meta-row">
|
||||||
</summary>
|
<span class="document-actions-dropdown__meta-label">Hash</span>
|
||||||
<dl class="meta-grid">
|
<code class="document-actions-dropdown__meta-value">{{ .Hash }}</code>
|
||||||
<div>
|
</div>
|
||||||
<dt>File</dt>
|
{{ if .Tags }}
|
||||||
<dd><code>{{ .Path }}</code></dd>
|
<div class="document-actions-dropdown__meta-row">
|
||||||
</div>
|
<span class="document-actions-dropdown__meta-label">Tags</span>
|
||||||
<div>
|
<ul class="document-actions-dropdown__tag-list">
|
||||||
<dt>Hash</dt>
|
{{ range .Tags }}
|
||||||
<dd><code>{{ .Hash }}</code></dd>
|
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
|
||||||
</div>
|
{{ end }}
|
||||||
{{ if .Tags }}
|
</ul>
|
||||||
<div>
|
</div>
|
||||||
<dt>Tags</dt>
|
{{ end }}
|
||||||
<dd>
|
|
||||||
<ul class="tag-list">
|
|
||||||
{{ range .Tags }}
|
|
||||||
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
</div>
|
||||||
</dl>
|
</details>
|
||||||
</details>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="markdown-body">
|
<div class="markdown-body">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<p class="eyebrow">Editing</p>
|
<p class="eyebrow">Editing</p>
|
||||||
<h1>{{ .Title }}</h1>
|
<h1>{{ .Title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}">Done</a>
|
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}" data-done-link>Done</a>
|
||||||
</div>
|
</div>
|
||||||
<details class="document-meta-panel">
|
<details class="document-meta-panel">
|
||||||
<summary>
|
<summary>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
<div class="editor-conflict__diff" data-conflict-diff></div>
|
<div class="editor-conflict__diff" data-conflict-diff></div>
|
||||||
<div class="editor-conflict__resolution">
|
<div class="editor-conflict__resolution">
|
||||||
<label for="conflict-resolution">Resolved document</label>
|
<label for="conflict-resolution">Resolved document</label>
|
||||||
<div data-conflict-monaco-mount></div>
|
<div data-conflict-codemirror-mount></div>
|
||||||
<textarea id="conflict-resolution" data-conflict-resolution spellcheck="false"></textarea>
|
<textarea id="conflict-resolution" data-conflict-resolution spellcheck="false"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<form class="editor-form" data-editor-form data-document-path="{{ .Path }}">
|
<form class="editor-form" data-editor-form data-document-path="{{ .Path }}">
|
||||||
<label class="sr-only" for="document-editor">Markdown editor</label>
|
<label class="sr-only" for="document-editor">Markdown editor</label>
|
||||||
<div data-monaco-mount></div>
|
<div data-codemirror-mount></div>
|
||||||
<textarea id="document-editor" name="content" data-document-editor spellcheck="false">{{ .Source }}</textarea>
|
<textarea id="document-editor" name="content" data-document-editor spellcheck="false">{{ .Source }}</textarea>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "$PORT:$PORT"
|
- "${PORT:-8080}:${PORT:-8080}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- mailpit
|
- mailpit
|
||||||
environment:
|
environment:
|
||||||
|
PORT: "${PORT:-8080}"
|
||||||
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"
|
||||||
@@ -20,7 +21,7 @@ services:
|
|||||||
- cairnquire_data:/workspace/data
|
- cairnquire_data:/workspace/data
|
||||||
- ./content:/workspace/content
|
- ./content:/workspace/content
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:${PORT}/healthz"]
|
test: ["CMD", "curl", "--fail", "http://127.0.0.1:${PORT:-8080}/healthz"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
Reference in New Issue
Block a user