server and web app

This commit is contained in:
2026-05-31 23:08:53 -04:00
parent 438b3b29a2
commit b3364447a1
24 changed files with 2318 additions and 153 deletions

View File

@@ -7,10 +7,13 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/email"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/httpserver"
@@ -52,10 +55,6 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
repo := docs.NewRepository(db.SQL())
service := docs.NewService(cfg.Content.SourceDir, contentStore, renderer, repo, logger)
hub := realtime.NewHub(logger)
service.OnChange(func(change docs.DocumentChange) {
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
})
if _, err := service.SyncSourceDir(ctx); err != nil {
logger.Warn("initial content sync failed", "error", err)
}
@@ -68,16 +67,33 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
return nil, fmt.Errorf("build auth service: %w", err)
}
collabRepo := collaboration.NewRepository(db.SQL())
var emailSender collaboration.EmailSender
if cfg.Email.SMTPHost != "" {
emailSender = email.NewSMTPSender(cfg.Email, logger)
} else {
emailSender = email.NewNoOpSender(logger)
}
collabService := collaboration.NewService(collabRepo, emailSender, hub, logger)
service.OnChange(func(change docs.DocumentChange) {
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
if err := collabService.NotifyDocumentChanged(ctx, formatDocumentID(change.Path), change.Path, "system"); err != nil {
logger.Warn("notify document changed", "error", err)
}
})
handler, err := httpserver.New(httpserver.Dependencies{
Config: cfg,
Logger: logger,
Documents: service,
Repository: repo,
ContentStore: contentStore,
Hub: hub,
SyncService: syncService,
SyncRepo: syncRepo,
Auth: authService,
Config: cfg,
Logger: logger,
Documents: service,
Repository: repo,
ContentStore: contentStore,
Hub: hub,
SyncService: syncService,
SyncRepo: syncRepo,
Auth: authService,
Collaboration: collabService,
})
if err != nil {
return nil, fmt.Errorf("build http handler: %w", err)
@@ -158,3 +174,9 @@ func (a *App) syncPoll(ctx context.Context) {
func (a *App) Close() error {
return a.db.Close()
}
func formatDocumentID(path string) string {
clean := strings.TrimPrefix(path, "/")
clean = strings.TrimSuffix(clean, ".md")
return "doc:" + clean
}

View File

@@ -0,0 +1,304 @@
package collaboration
import (
"context"
"database/sql"
"fmt"
"time"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// Comments
func (r *Repository) CreateComment(ctx context.Context, comment Comment) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO comments (id, document_id, version_hash, parent_id, author_id, content, anchor_hash, anchor_line, created_at, resolved_at, resolved_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, comment.ID, comment.DocumentID, comment.VersionHash, comment.ParentID, comment.AuthorID, comment.Content, comment.AnchorHash, comment.AnchorLine, formatTime(comment.CreatedAt), comment.ResolvedAt, comment.ResolvedBy)
if err != nil {
return fmt.Errorf("insert comment: %w", err)
}
return nil
}
func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error) {
var c Comment
var parentID, anchorHash sql.NullString
var anchorLine sql.NullInt64
var resolvedAt, resolvedBy sql.NullString
var createdAt string
err := r.db.QueryRowContext(ctx, `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.id = ?
`, id).Scan(
&c.ID, &c.DocumentID, &c.VersionHash, &parentID, &c.AuthorID, &c.AuthorName,
&c.Content, &anchorHash, &anchorLine, &createdAt, &resolvedAt, &resolvedBy,
)
if err != nil {
return nil, err
}
c.ParentID = nullString(parentID.String)
c.AnchorHash = nullString(anchorHash.String)
if anchorLine.Valid {
c.AnchorLine = ptr(int(anchorLine.Int64))
}
c.CreatedAt, _ = parseTime(createdAt)
if resolvedAt.Valid && resolvedAt.String != "" {
t, _ := parseTime(resolvedAt.String)
c.ResolvedAt = &t
c.ResolvedBy = nullString(resolvedBy.String)
}
return &c, nil
}
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ?
ORDER BY c.created_at ASC
`, documentID)
if err != nil {
return nil, fmt.Errorf("list comments: %w", err)
}
defer rows.Close()
return scanComments(rows)
}
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ? AND c.anchor_hash = ? AND c.resolved_at IS NULL
ORDER BY c.created_at ASC
`, documentID, anchorHash)
if err != nil {
return nil, fmt.Errorf("list comments by anchor: %w", err)
}
defer rows.Close()
return scanComments(rows)
}
func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE comments SET resolved_at = ?, resolved_by = ?
WHERE id = ?
`, formatTime(time.Now().UTC()), userID, commentID)
if err != nil {
return fmt.Errorf("resolve comment: %w", err)
}
return nil
}
func scanComments(rows *sql.Rows) ([]Comment, error) {
var comments []Comment
for rows.Next() {
var c Comment
var parentID, anchorHash sql.NullString
var anchorLine sql.NullInt64
var resolvedAt, resolvedBy sql.NullString
var createdAt string
if err := rows.Scan(
&c.ID, &c.DocumentID, &c.VersionHash, &parentID, &c.AuthorID, &c.AuthorName,
&c.Content, &anchorHash, &anchorLine, &createdAt, &resolvedAt, &resolvedBy,
); err != nil {
return nil, fmt.Errorf("scan comment: %w", err)
}
c.ParentID = nullString(parentID.String)
c.AnchorHash = nullString(anchorHash.String)
if anchorLine.Valid {
c.AnchorLine = ptr(int(anchorLine.Int64))
}
c.CreatedAt, _ = parseTime(createdAt)
if resolvedAt.Valid && resolvedAt.String != "" {
t, _ := parseTime(resolvedAt.String)
c.ResolvedAt = &t
c.ResolvedBy = nullString(resolvedBy.String)
}
comments = append(comments, c)
}
return comments, rows.Err()
}
// Notifications
func (r *Repository) CreateNotification(ctx context.Context, n Notification) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, read_at, emailed_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, n.ID, n.UserID, n.Type, n.ResourceType, n.ResourceID, n.Message, n.ReadAt, n.EmailedAt, formatTime(n.CreatedAt))
if err != nil {
return fmt.Errorf("insert notification: %w", err)
}
return nil
}
func (r *Repository) ListNotifications(ctx context.Context, userID string, unreadOnly bool, limit int) ([]Notification, error) {
query := `
SELECT id, user_id, type, resource_type, resource_id, message, read_at, emailed_at, created_at
FROM notifications
WHERE user_id = ?`
args := []any{userID}
if unreadOnly {
query += ` AND read_at IS NULL`
}
query += ` ORDER BY created_at DESC`
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
var notifications []Notification
for rows.Next() {
var n Notification
var readAt, emailedAt sql.NullString
var createdAt string
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.ResourceType, &n.ResourceID, &n.Message, &readAt, &emailedAt, &createdAt); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
if readAt.Valid && readAt.String != "" {
t, _ := parseTime(readAt.String)
n.ReadAt = &t
}
if emailedAt.Valid && emailedAt.String != "" {
t, _ := parseTime(emailedAt.String)
n.EmailedAt = &t
}
n.CreatedAt, _ = parseTime(createdAt)
notifications = append(notifications, n)
}
return notifications, rows.Err()
}
func (r *Repository) CountUnreadNotifications(ctx context.Context, userID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM notifications WHERE user_id = ? AND read_at IS NULL
`, userID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
}
return count, nil
}
func (r *Repository) MarkNotificationRead(ctx context.Context, notificationID string, userID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notifications SET read_at = ? WHERE id = ? AND user_id = ?
`, formatTime(time.Now().UTC()), notificationID, userID)
if err != nil {
return fmt.Errorf("mark notification read: %w", err)
}
return nil
}
func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL
`, formatTime(time.Now().UTC()), userID)
if err != nil {
return fmt.Errorf("mark all notifications read: %w", err)
}
return nil
}
// Watchers
func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO watchers (user_id, document_id, folder_path, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, document_id, folder_path) DO NOTHING
`, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt))
if err != nil {
return fmt.Errorf("add watcher: %w", err)
}
return nil
}
func (r *Repository) RemoveWatcher(ctx context.Context, userID string, documentID *string, folderPath *string) error {
_, err := r.db.ExecContext(ctx, `
DELETE FROM watchers WHERE user_id = ? AND document_id IS ? AND folder_path IS ?
`, userID, documentID, folderPath)
if err != nil {
return fmt.Errorf("remove watcher: %w", err)
}
return nil
}
func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, document_id, folder_path, created_at
FROM watchers
WHERE document_id = ?
`, documentID)
if err != nil {
return nil, fmt.Errorf("list document watchers: %w", err)
}
defer rows.Close()
return scanWatchers(rows)
}
func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, document_id, folder_path, created_at
FROM watchers
WHERE folder_path = ? OR ? || '/' = substr(folder_path, 1, length(?) + 1)
`, folderPath, folderPath, folderPath)
if err != nil {
return nil, fmt.Errorf("list folder watchers: %w", err)
}
defer rows.Close()
return scanWatchers(rows)
}
func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM watchers WHERE user_id = ? AND document_id = ?
`, userID, documentID).Scan(&count)
if err != nil {
return false, fmt.Errorf("check watching: %w", err)
}
return count > 0, nil
}
func scanWatchers(rows *sql.Rows) ([]Watcher, error) {
var watchers []Watcher
for rows.Next() {
var w Watcher
var docID, folderPath sql.NullString
var createdAt string
if err := rows.Scan(&w.UserID, &docID, &folderPath, &createdAt); err != nil {
return nil, fmt.Errorf("scan watcher: %w", err)
}
w.DocumentID = nullString(docID.String)
w.FolderPath = nullString(folderPath.String)
w.CreatedAt, _ = parseTime(createdAt)
watchers = append(watchers, w)
}
return watchers, rows.Err()
}

View File

@@ -0,0 +1,275 @@
package collaboration
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/realtime"
)
type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
type NoOpEmailSender struct{}
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
slog.Info("email stub", "to", to, "subject", subject, "body_len", len(body))
return nil
}
type Service struct {
repo *Repository
email EmailSender
hub *realtime.Hub
logger *slog.Logger
}
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
if email == nil {
email = &NoOpEmailSender{}
}
return &Service{
repo: repo,
email: email,
hub: hub,
logger: logger,
}
}
// Comments
func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, input CreateCommentInput) (*Comment, error) {
if principal.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
if input.Content == "" {
return nil, fmt.Errorf("content is required")
}
if input.DocumentID == "" {
return nil, fmt.Errorf("document_id is required")
}
comment := Comment{
ID: randomID("comment"),
DocumentID: input.DocumentID,
VersionHash: input.VersionHash,
AuthorID: principal.UserID,
Content: input.Content,
CreatedAt: time.Now().UTC(),
}
if input.ParentID != nil && *input.ParentID != "" {
comment.ParentID = input.ParentID
}
if input.AnchorHash != nil && *input.AnchorHash != "" {
comment.AnchorHash = input.AnchorHash
}
if input.AnchorLine != nil && *input.AnchorLine > 0 {
comment.AnchorLine = input.AnchorLine
}
if err := s.repo.CreateComment(ctx, comment); err != nil {
return nil, err
}
// Create notifications for watchers
if err := s.notifyWatchersOfComment(ctx, comment); err != nil {
s.logger.Warn("failed to notify watchers of comment", "error", err)
}
// Broadcast to connected clients
s.broadcastNotification(principal.UserID)
return s.repo.GetComment(ctx, comment.ID)
}
func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) {
return s.repo.ListCommentsByDocument(ctx, documentID)
}
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
}
func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.ResolveComment(ctx, commentID, principal.UserID)
}
// Notifications
func (s *Service) ListNotifications(ctx context.Context, principal auth.Principal, unreadOnly bool, limit int) ([]Notification, error) {
if principal.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
return s.repo.ListNotifications(ctx, principal.UserID, unreadOnly, limit)
}
func (s *Service) GetBellStatus(ctx context.Context, principal auth.Principal) (*NotificationBell, error) {
if principal.UserID == "" {
return &NotificationBell{UnreadCount: 0, Items: []Notification{}}, nil
}
count, err := s.repo.CountUnreadNotifications(ctx, principal.UserID)
if err != nil {
return nil, err
}
items, err := s.repo.ListNotifications(ctx, principal.UserID, false, 20)
if err != nil {
return nil, err
}
return &NotificationBell{
UnreadCount: count,
Items: items,
}, nil
}
func (s *Service) MarkNotificationRead(ctx context.Context, principal auth.Principal, notificationID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.MarkNotificationRead(ctx, notificationID, principal.UserID)
}
func (s *Service) MarkAllNotificationsRead(ctx context.Context, principal auth.Principal) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.MarkAllNotificationsRead(ctx, principal.UserID)
}
// Watchers
func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
DocumentID: &documentID,
CreatedAt: time.Now().UTC(),
})
}
func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
}
func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
FolderPath: &folderPath,
CreatedAt: time.Now().UTC(),
})
}
func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
}
func (s *Service) IsWatching(ctx context.Context, principal auth.Principal, documentID string) (bool, error) {
if principal.UserID == "" {
return false, nil
}
return s.repo.IsWatching(ctx, principal.UserID, documentID)
}
// Document change notifications
func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, documentPath string, actorName string) error {
watchers, err := s.repo.ListWatchersByDocument(ctx, documentID)
if err != nil {
return fmt.Errorf("list document watchers: %w", err)
}
folderWatchers, err := s.repo.ListWatchersByFolder(ctx, documentPath)
if err != nil {
return fmt.Errorf("list folder watchers: %w", err)
}
seen := make(map[string]bool)
for _, w := range append(watchers, folderWatchers...) {
if seen[w.UserID] {
continue
}
seen[w.UserID] = true
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
notification := Notification{
ID: randomID("notif"),
UserID: w.UserID,
Type: "file_changed",
ResourceType: "document",
ResourceID: documentID,
Message: msg,
CreatedAt: time.Now().UTC(),
}
if err := s.repo.CreateNotification(ctx, notification); err != nil {
s.logger.Warn("create notification", "error", err)
continue
}
}
// Broadcast bell update to all connected clients
s.broadcastNotification("")
return nil
}
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID)
if err != nil {
return err
}
for _, w := range watchers {
if w.UserID == comment.AuthorID {
continue
}
msg := fmt.Sprintf("New comment on document")
if comment.AnchorHash != nil {
msg = fmt.Sprintf("New comment on a section of the document")
}
notification := Notification{
ID: randomID("notif"),
UserID: w.UserID,
Type: "comment",
ResourceType: "document",
ResourceID: comment.DocumentID,
Message: msg,
CreatedAt: time.Now().UTC(),
}
if err := s.repo.CreateNotification(ctx, notification); err != nil {
s.logger.Warn("create comment notification", "error", err)
}
}
return nil
}
func (s *Service) broadcastNotification(userID string) {
if s.hub == nil {
return
}
s.hub.Broadcast(realtime.Event{
Type: "notification",
Data: map[string]string{"userId": userID},
})
}
func (s *Service) SetEmailSender(email EmailSender) {
if email != nil {
s.email = email
}
}

View File

@@ -0,0 +1,51 @@
package collaboration
import "time"
type Comment struct {
ID string `json:"id"`
DocumentID string `json:"documentId"`
VersionHash string `json:"versionHash"`
ParentID *string `json:"parentId,omitempty"`
AuthorID string `json:"authorId"`
AuthorName string `json:"authorName"`
Content string `json:"content"`
AnchorHash *string `json:"anchorHash,omitempty"`
AnchorLine *int `json:"anchorLine,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ResolvedAt *time.Time `json:"resolvedAt,omitempty"`
ResolvedBy *string `json:"resolvedBy,omitempty"`
}
type CreateCommentInput struct {
DocumentID string `json:"documentId"`
VersionHash string `json:"versionHash"`
ParentID *string `json:"parentId,omitempty"`
Content string `json:"content"`
AnchorHash *string `json:"anchorHash,omitempty"`
AnchorLine *int `json:"anchorLine,omitempty"`
}
type Notification struct {
ID string `json:"id"`
UserID string `json:"userId"`
Type string `json:"type"`
ResourceType string `json:"resourceType"`
ResourceID string `json:"resourceId"`
Message string `json:"message"`
ReadAt *time.Time `json:"readAt,omitempty"`
EmailedAt *time.Time `json:"emailedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type Watcher struct {
UserID string `json:"userId"`
DocumentID *string `json:"documentId,omitempty"`
FolderPath *string `json:"folderPath,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type NotificationBell struct {
UnreadCount int `json:"unreadCount"`
Items []Notification `json:"items"`
}

View File

@@ -0,0 +1,71 @@
package collaboration
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
func randomID(prefix string) string {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return prefix + ":" + hex.EncodeToString(buf)
}
func hashAnchor(text string) string {
normalized := strings.TrimSpace(text)
sum := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(sum[:8])
}
func parseTime(raw string) (time.Time, error) {
if raw == "" {
return time.Time{}, nil
}
return time.Parse(time.RFC3339, raw)
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
func ptr[T any](v T) *T {
return &v
}
func nullString(s string) *string {
if s == "" {
return nil
}
return &s
}
func scanNullString(ns *string) string {
if ns == nil {
return ""
}
return *ns
}
func coalesceString(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func formatDocumentID(path string) string {
clean := strings.TrimPrefix(path, "/")
clean = strings.TrimSuffix(clean, ".md")
return "doc:" + clean
}
func documentPathFromID(id string) string {
return strings.TrimPrefix(id, "doc:")
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
)
type Config struct {
@@ -13,6 +14,7 @@ type Config struct {
Content ContentConfig `json:"content"`
Web WebConfig `json:"web"`
Auth AuthConfig `json:"auth"`
Email EmailConfig `json:"email"`
LogLevel string `json:"logLevel"`
}
@@ -40,6 +42,12 @@ type AuthConfig struct {
PublicOrigin string `json:"publicOrigin"`
}
type EmailConfig struct {
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
From string `json:"from"`
}
func Default() Config {
return Config{
Server: ServerConfig{
@@ -59,6 +67,11 @@ func Default() Config {
Auth: AuthConfig{
PublicOrigin: "http://localhost:8080",
},
Email: EmailConfig{
SMTPHost: "localhost",
SMTPPort: 1025,
From: "notifications@cairnquire.local",
},
LogLevel: "INFO",
}
}
@@ -81,6 +94,13 @@ func Load() (Config, error) {
overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR")
overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL")
overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
cfg.Email.SMTPPort = p
}
}
overrideString(&cfg.Email.From, "CAIRNQUIRE_EMAIL_FROM")
overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL")
if cfg.Server.Addr == "" {

View File

@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_watchers_folder;
DROP INDEX IF EXISTS idx_watchers_document;
DROP TABLE IF EXISTS watchers;
DROP INDEX IF EXISTS idx_notifications_created_at;
DROP INDEX IF EXISTS idx_notifications_unread;
DROP INDEX IF EXISTS idx_notifications_user;
DROP TABLE IF EXISTS notifications;
DROP INDEX IF EXISTS idx_comments_author;
DROP INDEX IF EXISTS idx_comments_parent;
DROP INDEX IF EXISTS idx_comments_document;
DROP TABLE IF EXISTS comments;

View File

@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_hash TEXT NOT NULL,
parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
anchor_hash TEXT,
anchor_line INTEGER,
created_at TEXT NOT NULL,
resolved_at TEXT,
resolved_by TEXT REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_comments_document ON comments(document_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
CREATE INDEX IF NOT EXISTS idx_comments_author ON comments(author_id);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK(type IN ('file_changed', 'comment', 'mention', 'conflict')),
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
message TEXT NOT NULL,
read_at TEXT,
emailed_at TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at) WHERE read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
CREATE TABLE IF NOT EXISTS watchers (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
document_id TEXT REFERENCES documents(id) ON DELETE CASCADE,
folder_path TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, document_id, folder_path)
);
CREATE INDEX IF NOT EXISTS idx_watchers_document ON watchers(document_id);
CREATE INDEX IF NOT EXISTS idx_watchers_folder ON watchers(folder_path);

View File

@@ -0,0 +1,67 @@
package email
import (
"context"
"fmt"
"log/slog"
"net/smtp"
"github.com/tim/cairnquire/apps/server/internal/config"
)
type Sender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
type SMTPConfig struct {
Host string
Port int
From string
}
type SMTPSender struct {
cfg SMTPConfig
logger *slog.Logger
}
func NewSMTPSender(cfg config.EmailConfig, logger *slog.Logger) *SMTPSender {
return &SMTPSender{
cfg: SMTPConfig{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
From: cfg.From,
},
logger: logger,
}
}
func (s *SMTPSender) Send(ctx context.Context, to []string, subject, body string) error {
if s.cfg.Host == "" {
return fmt.Errorf("email host not configured")
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to[0], subject, body))
s.logger.Info("sending email", "to", to, "subject", subject, "addr", addr)
// Use SMTP without auth for local testing (Mailpit/MailHog)
var auth smtp.Auth
err := smtp.SendMail(addr, auth, s.cfg.From, to, msg)
if err != nil {
return fmt.Errorf("send email: %w", err)
}
return nil
}
type NoOpSender struct {
logger *slog.Logger
}
func NewNoOpSender(logger *slog.Logger) *NoOpSender {
return &NoOpSender{logger: logger}
}
func (n *NoOpSender) Send(ctx context.Context, to []string, subject, body string) error {
n.logger.Info("email noop", "to", to, "subject", subject, "body_len", len(body))
return nil
}

View File

@@ -0,0 +1,390 @@
package httpserver
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/realtime"
"github.com/tim/cairnquire/apps/server/internal/store"
"github.com/tim/cairnquire/apps/server/internal/sync"
)
type apiTestServer struct {
handler http.Handler
auth *auth.Service
docs *docs.Service
userID string
root string
}
func newAPITestServer(t *testing.T) *apiTestServer {
t.Helper()
ctx := context.Background()
root := t.TempDir()
sourceDir := filepath.Join(root, "content")
storeDir := filepath.Join(root, "files")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatalf("create source dir: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nInitial"), 0o644); err != nil {
t.Fatalf("write source fixture: %v", err)
}
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(root, "db.sqlite")})
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
t.Fatalf("apply migrations: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
docRepo := docs.NewRepository(db.SQL())
docService := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), docRepo, logger)
if _, err := docService.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync source fixture: %v", err)
}
authService, err := auth.NewService(auth.NewRepository(db.SQL()), "http://localhost:8080")
if err != nil {
t.Fatalf("create auth service: %v", err)
}
user, err := authService.RegisterPasswordUser(ctx, "editor@example.com", "Editor", "very secure password", string(auth.RoleEditor))
if err != nil {
t.Fatalf("create test user: %v", err)
}
syncRepo := sync.NewRepository(db.SQL())
handler, err := New(Dependencies{
Config: config.Config{
Content: config.ContentConfig{
SourceDir: sourceDir,
StoreDir: storeDir,
},
Web: config.WebConfig{DistDir: filepath.Join(root, "web-dist")},
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
},
Logger: logger,
Documents: docService,
Repository: docRepo,
ContentStore: contentStore,
Hub: realtime.NewHub(logger),
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo,
Auth: authService,
})
if err != nil {
t.Fatalf("create http server: %v", err)
}
return &apiTestServer{
handler: handler,
auth: authService,
docs: docService,
userID: user.ID,
root: root,
}
}
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper()
created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil)
if err != nil {
t.Fatalf("create api token: %v", err)
}
return created.Token
}
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
body, contentType := multipartBody(t, "file", `unsafe"name.txt`, []byte("plain attachment\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
request.Header.Set("Content-Type", contentType)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
response := recorder.Result()
if response.StatusCode != http.StatusCreated {
t.Fatalf("upload status = %d, want %d; body=%s", response.StatusCode, http.StatusCreated, recorder.Body.String())
}
var payload struct {
Hash string `json:"hash"`
ContentType string `json:"contentType"`
AttachmentURL string `json:"attachmentUrl"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if payload.Hash == "" {
t.Fatal("expected upload response hash")
}
if payload.ContentType != "text/plain; charset=utf-8" {
t.Fatalf("contentType = %q, want text/plain; charset=utf-8", payload.ContentType)
}
if payload.AttachmentURL != "/attachments/"+payload.Hash {
t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash)
}
download := httptest.NewRequest(http.MethodGet, payload.AttachmentURL, nil)
downloadRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(downloadRecorder, download)
downloadResponse := downloadRecorder.Result()
if downloadResponse.StatusCode != http.StatusOK {
t.Fatalf("download status = %d, want %d", downloadResponse.StatusCode, http.StatusOK)
}
if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType {
t.Fatalf("download content-type = %q, want %q", got, payload.ContentType)
}
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.txt"` {
t.Fatalf("content-disposition = %q, want sanitized filename", got)
}
if downloadRecorder.Body.String() != "plain attachment\n" {
t.Fatalf("download body = %q", downloadRecorder.Body.String())
}
}
func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
body, contentType := multipartBody(t, "file", "note.txt", []byte("plain attachment\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
request.Header.Set("Content-Type", contentType)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("sync init status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var payload struct {
SnapshotID string `json:"snapshotId"`
ServerSnapshot []sync.FileEntry `json:"serverSnapshot"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode sync init response: %v", err)
}
if payload.SnapshotID == "" {
t.Fatal("expected snapshot id")
}
if len(payload.ServerSnapshot) != 1 || payload.ServerSnapshot[0].Path != "hello.md" {
t.Fatalf("server snapshot = %#v, want hello.md entry", payload.ServerSnapshot)
}
fetch := httptest.NewRequest(http.MethodGet, "/api/content/"+payload.ServerSnapshot[0].Hash, nil)
fetch.Header.Set("Authorization", "Bearer "+token)
fetchRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(fetchRecorder, fetch)
if fetchRecorder.Code != http.StatusOK {
t.Fatalf("content fetch status = %d, want %d", fetchRecorder.Code, http.StatusOK)
}
if fetchRecorder.Body.String() != "# Hello\n\nInitial" {
t.Fatalf("content fetch body = %q", fetchRecorder.Body.String())
}
}
func TestAPISyncInitRejectsTokenWithoutSyncWriteScope(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-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())
}
var initPayload struct {
SnapshotID string `json:"snapshotId"`
}
if err := json.Unmarshal(initRecorder.Body.Bytes(), &initPayload); err != nil {
t.Fatalf("decode sync init response: %v", err)
}
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer update"), 0o644); err != nil {
t.Fatalf("write server update: %v", err)
}
deltaRequest := jsonRequest(http.MethodPost, "/api/sync/delta", map[string]any{
"snapshotId": initPayload.SnapshotID,
"clientDelta": []sync.Change{},
})
deltaRequest.Header.Set("Authorization", "Bearer "+token)
deltaRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(deltaRecorder, deltaRequest)
if deltaRecorder.Code != http.StatusOK {
t.Fatalf("sync delta status = %d, want %d; body=%s", deltaRecorder.Code, http.StatusOK, deltaRecorder.Body.String())
}
var deltaPayload struct {
ServerDelta []sync.Change `json:"serverDelta"`
Conflicts []sync.Conflict `json:"conflicts"`
}
if err := json.Unmarshal(deltaRecorder.Body.Bytes(), &deltaPayload); err != nil {
t.Fatalf("decode sync delta response: %v", err)
}
if len(deltaPayload.ServerDelta) != 1 {
t.Fatalf("serverDelta = %#v, want one update", deltaPayload.ServerDelta)
}
change := deltaPayload.ServerDelta[0]
if change.Type != sync.ChangeUpdate || change.Path != "hello.md" || change.Hash == "" {
t.Fatalf("server delta change = %#v, want update for hello.md with hash", change)
}
if len(deltaPayload.Conflicts) != 0 {
t.Fatalf("conflicts = %#v, want none", deltaPayload.Conflicts)
}
}
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
var list struct {
Documents []struct {
Path string `json:"path"`
Hash string `json:"hash"`
} `json:"documents"`
}
if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil {
t.Fatalf("decode documents: %v", err)
}
if len(list.Documents) != 1 {
t.Fatalf("documents = %#v, want one document", list.Documents)
}
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer edit"), 0o644); err != nil {
t.Fatalf("write server edit: %v", err)
}
save := jsonRequest(http.MethodPost, "/api/documents/hello", map[string]string{
"content": "# Hello\n\nClient edit",
"baseHash": list.Documents[0].Hash,
})
save.Header.Set("Authorization", "Bearer "+token)
saveRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(saveRecorder, save)
if saveRecorder.Code != http.StatusConflict {
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusConflict, saveRecorder.Body.String())
}
var conflict struct {
Status string `json:"status"`
Path string `json:"path"`
BaseHash string `json:"baseHash"`
CurrentHash string `json:"currentHash"`
CurrentContent string `json:"currentContent"`
}
if err := json.Unmarshal(saveRecorder.Body.Bytes(), &conflict); err != nil {
t.Fatalf("decode conflict response: %v", err)
}
if conflict.Status != "conflict" || conflict.Path != "hello.md" {
t.Fatalf("conflict payload = %#v, want conflict for hello.md", conflict)
}
if conflict.BaseHash != list.Documents[0].Hash {
t.Fatalf("baseHash = %q, want %q", conflict.BaseHash, list.Documents[0].Hash)
}
if conflict.CurrentHash == "" || conflict.CurrentHash == conflict.BaseHash {
t.Fatalf("currentHash = %q, baseHash = %q", conflict.CurrentHash, conflict.BaseHash)
}
if conflict.CurrentContent != "# Hello\n\nServer edit" {
t.Fatalf("currentContent = %q", conflict.CurrentContent)
}
}
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
t.Fatalf("create multipart file: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write multipart file: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
return body, writer.FormDataContentType()
}
func jsonRequest(method, path string, payload any) *http.Request {
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
request := httptest.NewRequest(method, path, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
return request
}

View File

@@ -0,0 +1,262 @@
package httpserver
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
)
// Comments
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
documentID := r.URL.Query().Get("document_id")
if documentID == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"})
return
}
anchorHash := r.URL.Query().Get("anchor_hash")
var comments []collaboration.Comment
var err error
if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
} else {
comments, err = s.collaboration.ListComments(r.Context(), documentID)
}
if err != nil {
s.logger.Warn("list comments", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"comments": comments})
}
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var input collaboration.CreateCommentInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
comment, err := s.collaboration.CreateComment(r.Context(), principal, input)
if err != nil {
s.logger.Warn("create comment", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusCreated, comment)
}
func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
commentID := chi.URLParam(r, "id")
if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil {
s.logger.Warn("resolve comment", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// Notifications
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
unreadOnly := r.URL.Query().Get("unread") == "true"
limit := 50
if r.URL.Query().Get("limit") != "" {
// Parse limit if needed, but for now just use default
}
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
if err != nil {
s.logger.Warn("list notifications", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": notifications})
}
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
notificationID := chi.URLParam(r, "id")
if err := s.collaboration.MarkNotificationRead(r.Context(), principal, notificationID); err != nil {
s.logger.Warn("mark notification read", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if err := s.collaboration.MarkAllNotificationsRead(r.Context(), principal); err != nil {
s.logger.Warn("mark all notifications read", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleNotificationBell(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSON(w, http.StatusOK, collaboration.NotificationBell{UnreadCount: 0, Items: []collaboration.Notification{}})
return
}
bell, err := s.collaboration.GetBellStatus(r.Context(), principal)
if err != nil {
s.logger.Warn("notification bell", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, bell)
}
// Watchers
func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
DocumentID string `json:"documentId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("watch document", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "watching"})
}
func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
DocumentID string `json:"documentId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("unwatch document", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"})
}
func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil {
s.logger.Warn("watch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "watching"})
}
func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil {
s.logger.Warn("unwatch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"})
}
func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSON(w, http.StatusOK, map[string]bool{"watching": false})
return
}
documentID := r.URL.Query().Get("document_id")
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
if err != nil {
s.logger.Warn("is watching", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"watching": watching})
}
// Document page with comments
func (s *Server) renderDocumentPageWithComments(w http.ResponseWriter, r *http.Request, pagePath string) {
// This is a helper that could be used to inject comments into the template
// For now, comments are fetched client-side via JS
s.renderDocumentPage(w, r, pagePath)
}

View File

@@ -127,6 +127,12 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeSyncRead, true
case strings.HasPrefix(path, "/api/content/"):
return auth.ScopeSyncRead, true
case strings.HasPrefix(path, "/api/comments"):
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/notifications"):
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"):
return auth.ScopeDocsRead, true
case strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true
default:

View File

@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/realtime"
@@ -24,30 +25,32 @@ import (
var assets embed.FS
type Dependencies struct {
Config config.Config
Logger *slog.Logger
Documents *docs.Service
Repository *docs.Repository
ContentStore *store.ContentStore
Hub *realtime.Hub
SyncService *sync.Service
SyncRepo *sync.Repository
Auth *auth.Service
Config config.Config
Logger *slog.Logger
Documents *docs.Service
Repository *docs.Repository
ContentStore *store.ContentStore
Hub *realtime.Hub
SyncService *sync.Service
SyncRepo *sync.Repository
Auth *auth.Service
Collaboration *collaboration.Service
}
type Server struct {
config config.Config
logger *slog.Logger
documents *docs.Service
repository *docs.Repository
contentStore *store.ContentStore
hub *realtime.Hub
syncService *sync.Service
syncRepo *sync.Repository
auth *auth.Service
authLimiter *rateLimiter
templates *template.Template
webEnabled bool
config config.Config
logger *slog.Logger
documents *docs.Service
repository *docs.Repository
contentStore *store.ContentStore
hub *realtime.Hub
syncService *sync.Service
syncRepo *sync.Repository
auth *auth.Service
collaboration *collaboration.Service
authLimiter *rateLimiter
templates *template.Template
webEnabled bool
}
func New(deps Dependencies) (http.Handler, error) {
@@ -64,17 +67,18 @@ func New(deps Dependencies) (http.Handler, error) {
}
server := &Server{
config: deps.Config,
logger: deps.Logger,
documents: deps.Documents,
repository: deps.Repository,
contentStore: deps.ContentStore,
hub: deps.Hub,
syncService: deps.SyncService,
syncRepo: deps.SyncRepo,
auth: deps.Auth,
authLimiter: newRateLimiter(5, time.Minute),
templates: templates,
config: deps.Config,
logger: deps.Logger,
documents: deps.Documents,
repository: deps.Repository,
contentStore: deps.ContentStore,
hub: deps.Hub,
syncService: deps.SyncService,
syncRepo: deps.SyncRepo,
auth: deps.Auth,
collaboration: deps.Collaboration,
authLimiter: newRateLimiter(5, time.Minute),
templates: templates,
}
if _, err := os.Stat(deps.Config.Web.DistDir); err == nil {
@@ -129,6 +133,20 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/uploads", server.handleUpload)
router.Get("/attachments/{hash}", server.handleAttachment)
// Collaboration endpoints
router.Get("/api/comments", server.handleListComments)
router.Post("/api/comments", server.handleCreateComment)
router.Post("/api/comments/{id}/resolve", server.handleResolveComment)
router.Get("/api/notifications", server.handleListNotifications)
router.Post("/api/notifications/{id}/read", server.handleMarkNotificationRead)
router.Post("/api/notifications/read-all", server.handleMarkAllNotificationsRead)
router.Get("/api/notifications/bell", server.handleNotificationBell)
router.Post("/api/watch/document", server.handleWatchDocument)
router.Post("/api/watch/folder", server.handleWatchFolder)
router.Delete("/api/watch/document", server.handleUnwatchDocument)
router.Delete("/api/watch/folder", server.handleUnwatchFolder)
router.Get("/api/watch/status", server.handleIsWatching)
// Sync protocol endpoints
router.Post("/api/sync/init", server.handleSyncInit)
router.Post("/api/sync/delta", server.handleSyncDelta)

View File

@@ -1,4 +1,56 @@
(() => {
// Tab switching
document.querySelectorAll('.auth-tab-list').forEach(tabList => {
const tabs = tabList.querySelectorAll('[data-tab]');
const panels = tabList.closest('.auth-tabs').querySelectorAll('[data-tab-panel]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
tabs.forEach(t => {
t.classList.remove('is-active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('is-active');
tab.setAttribute('aria-selected', 'true');
panels.forEach(p => {
if (p.dataset.tabPanel === target) {
p.classList.add('is-active');
p.hidden = false;
} else {
p.classList.remove('is-active');
p.hidden = true;
}
});
});
});
});
// Register toggle
const registerToggle = document.querySelector('[data-register-toggle]');
const registerPanel = document.querySelector('[data-register-panel]');
const loginSection = document.querySelector('[data-login-section]');
const registerCancel = document.querySelector('[data-register-cancel]');
if (registerToggle && registerPanel && loginSection) {
registerToggle.addEventListener('click', () => {
loginSection.hidden = true;
registerPanel.hidden = false;
});
}
if (registerCancel && registerPanel && loginSection) {
registerCancel.addEventListener('click', () => {
registerPanel.hidden = true;
loginSection.hidden = false;
// Clear any registration form status messages
document.querySelector('[data-passkey-register-status]').textContent = '';
document.querySelector('[data-register-status]').textContent = '';
});
}
const jsonHeaders = { "Content-Type": "application/json" };
const postJSON = async (url, body, options = {}) => {
@@ -95,35 +147,76 @@
}
});
const webAuthnErrorMessage = (error) => {
console.error("WebAuthn error:", error.name, error.message, error);
switch (error.name) {
case "NotAllowedError":
return "Passkey creation was cancelled or no authenticator is available. If you dismissed a prompt, try again. If no prompt appeared, your device may not support passkeys.";
case "NotSupportedError":
return "This browser or device does not support passkeys.";
case "SecurityError":
return "Passkeys require a secure context. Use https:// or localhost.";
case "InvalidStateError":
return "A passkey may already exist for this account.";
default:
return error.message || "Passkey failed. Check the console for details.";
}
};
const checkPasskeySupport = async () => {
if (!window.PublicKeyCredential) {
return { supported: false, reason: "Passkeys are not available in this browser." };
}
try {
if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!available) {
return { supported: true, reason: "No platform authenticator found (e.g., Touch ID, Windows Hello). You can still use a security key if you have one." };
}
}
return { supported: true };
} catch {
return { supported: true };
}
};
document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => {
event.preventDefault();
if (!window.PublicKeyCredential) {
setStatus("[data-passkey-register-status]", "Passkeys are not available in this browser.", true);
const support = await checkPasskeySupport();
if (!support.supported) {
setStatus("[data-passkey-register-status]", support.reason, true);
return;
}
if (support.reason) {
console.warn("Passkey support:", support.reason);
}
try {
const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget));
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey.");
} catch (error) {
setStatus("[data-passkey-register-status]", error.message, true);
setStatus("[data-passkey-register-status]", webAuthnErrorMessage(error), true);
}
});
document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => {
event.preventDefault();
if (!window.PublicKeyCredential) {
setStatus("[data-passkey-login-status]", "Passkeys are not available in this browser.", true);
const support = await checkPasskeySupport();
if (!support.supported) {
setStatus("[data-passkey-login-status]", support.reason, true);
return;
}
if (support.reason) {
console.warn("Passkey support:", support.reason);
}
try {
const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget));
const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options));
await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
window.location.href = authNext();
} catch (error) {
setStatus("[data-passkey-login-status]", error.message, true);
setStatus("[data-passkey-login-status]", webAuthnErrorMessage(error), true);
}
});

View File

@@ -0,0 +1,123 @@
(function () {
'use strict';
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
if (!documentId) return;
// Hash a paragraph's text content for anchoring
function hashParagraph(el) {
const text = (el.textContent || '').trim();
if (!text) return null;
// Simple hash: first 16 chars of base64 of char codes (deterministic)
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'h' + Math.abs(hash).toString(36);
}
function addCommentButton(el, hash) {
const btn = document.createElement('button');
btn.className = 'comment-anchor-btn';
btn.title = 'Add comment';
btn.innerHTML = '+';
btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn);
}
async function promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId,
versionHash: documentHash,
content: text.trim(),
anchorHash,
}),
});
if (!res.ok) throw new Error(await res.text());
// Refresh comments display
await loadCommentsForAnchor(el, anchorHash);
} catch (err) {
window.alert('Failed to post comment: ' + err.message);
}
}
async function loadCommentsForAnchor(el, anchorHash) {
try {
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
if (!res.ok) return;
const data = await res.json();
renderComments(el, data.comments || []);
} catch (err) {
console.error('load comments', err);
}
}
function renderComments(el, comments) {
// Remove existing comment list
const existing = el.querySelector('.comment-list');
if (existing) existing.remove();
if (!comments.length) return;
const list = document.createElement('div');
list.className = 'comment-list';
comments.forEach(c => {
const item = document.createElement('div');
item.className = 'comment-item';
item.innerHTML = `
<div class="comment-meta">
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
<time>${formatDate(c.createdAt)}</time>
</div>
<div class="comment-body">${escapeHtml(c.content)}</div>
`;
list.appendChild(item);
});
el.appendChild(list);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
// Initialize: hash all paragraphs in markdown body
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
paragraphs.forEach(el => {
const hash = hashParagraph(el);
if (!hash) return;
el.dataset.anchorHash = hash;
el.classList.add('commentable');
addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,123 @@
(function () {
'use strict';
const bell = document.querySelector('[data-notification-bell]');
if (!bell) return;
const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]');
let isOpen = false;
bell.addEventListener('click', () => {
isOpen = !isOpen;
dropdown.hidden = !isOpen;
if (isOpen) loadNotifications();
});
document.addEventListener('click', (e) => {
if (!bell.contains(e.target)) {
isOpen = false;
dropdown.hidden = true;
}
});
async function updateBell() {
try {
const res = await fetch('/api/notifications/bell');
if (!res.ok) return;
const data = await res.json();
const count = data.unreadCount || 0;
countEl.textContent = count > 99 ? '99+' : String(count);
countEl.hidden = count === 0;
bell.classList.toggle('has-unread', count > 0);
} catch (err) {
console.error('bell update', err);
}
}
async function loadNotifications() {
try {
const res = await fetch('/api/notifications?limit=20');
if (!res.ok) return;
const data = await res.json();
renderNotifications(data.notifications || []);
} catch (err) {
console.error('load notifications', err);
}
}
function renderNotifications(notifications) {
const list = dropdown.querySelector('ul');
if (!list) return;
list.innerHTML = '';
if (!notifications.length) {
list.innerHTML = '<li class="notification-empty">No notifications</li>';
return;
}
notifications.forEach(n => {
const li = document.createElement('li');
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
li.innerHTML = `
<div class="notification-message">${escapeHtml(n.message)}</div>
<time class="notification-time">${formatDate(n.createdAt)}</time>
`;
if (!n.readAt) {
li.addEventListener('click', () => markRead(n.id));
}
list.appendChild(li);
});
// Add "Mark all read" button
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
btn.textContent = 'Mark all read';
btn.addEventListener('click', markAllRead);
footer.appendChild(btn);
list.appendChild(footer);
}
async function markRead(id) {
try {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
updateBell();
loadNotifications();
} catch (err) {
console.error('mark read', err);
}
}
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
updateBell();
loadNotifications();
} catch (err) {
console.error('mark all read', err);
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
// Listen for WebSocket notification events
if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => {
updateBell();
});
}
// Poll every 60s as fallback
setInterval(updateBell, 60000);
updateBell();
})();

View File

@@ -1,4 +1,23 @@
(function () {
const realtimeCallbacks = {
notification: [],
};
window.__cairnquireRealtime = {
on: function (event, cb) {
if (realtimeCallbacks[event]) {
realtimeCallbacks[event].push(cb);
}
},
off: function (event, cb) {
if (realtimeCallbacks[event]) {
realtimeCallbacks[event] = realtimeCallbacks[event].filter(function (c) {
return c !== cb;
});
}
},
};
const notice = document.querySelector("[data-version-notice]");
const reload = document.querySelector("[data-version-reload]");
const offlineNotice = document.querySelector("[data-offline-notice]");
@@ -300,6 +319,13 @@
return;
}
if (payload.type === "notification") {
realtimeCallbacks.notification.forEach(function (cb) {
cb(payload.data);
});
return;
}
if (payload.type !== "document_version" || !payload.data) {
return;
}

View File

@@ -106,45 +106,61 @@ code {
.site-nav {
display: flex;
gap: 1.25rem;
gap: 0.5rem;
}
.site-nav a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
color: inherit;
text-decoration: none;
font-size: 0.95rem;
transition: background 0.15s;
}
.site-nav a:hover {
background: rgba(0, 0, 0, 0.06);
}
.site-nav a svg {
display: block;
}
.site-search {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
max-width: 320px;
margin: 0 1rem;
}
.site-search__icon {
position: absolute;
left: 0.6rem;
z-index: 1;
color: var(--muted);
pointer-events: none;
}
.site-search input {
width: 100%;
padding: 0.45rem 0.75rem;
padding: 0.4rem 0.75rem 0.4rem 2rem;
border: 1px solid var(--border);
border-radius: 0;
background: var(--panel-strong);
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
font: inherit;
font-size: 0.9rem;
font-size: 0.825rem;
transition: border-color 0.15s, background 0.15s;
}
.site-search input:focus {
outline: 2px solid var(--accent-soft);
outline: none;
border-color: var(--accent);
}
.site-search button {
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-strong);
cursor: pointer;
font-size: 0.9rem;
}
.site-main {
@@ -250,11 +266,30 @@ code {
cursor: pointer;
}
.btn-primary,
.auth-form button[type="submit"],
.account-header button {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
background: var(--accent);
color: white;
padding: 0.85rem 1.75rem;
min-height: 3rem;
font-size: 1.05rem;
font-weight: 600;
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
.btn-primary:hover,
.auth-form button[type="submit"]:hover {
background: color-mix(in srgb, var(--accent) 85%, black);
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 40%, transparent);
}
.btn-primary:active,
.auth-form button[type="submit"]:active {
transform: translateY(1px);
box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 30%, transparent);
}
.auth-details {
@@ -289,6 +324,123 @@ code {
color: var(--muted);
}
/* Auth tabs */
.auth-tabs {
margin-bottom: 1.5rem;
}
.auth-tab-list {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.auth-tab {
padding: 0.5rem 1rem;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.auth-tab:hover {
color: var(--text);
}
.auth-tab.is-active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.auth-tab-panel {
display: none;
}
.auth-tab-panel.is-active {
display: block;
}
/* Register CTA */
.auth-register-cta {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
text-align: center;
}
.btn-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 1rem 2rem;
min-height: 3.5rem;
border: 2px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--accent);
color: white;
font: inherit;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 35%, transparent);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
.btn-cta:hover {
background: color-mix(in srgb, var(--accent) 85%, black);
box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 45%, transparent);
}
.btn-cta:active {
transform: translateY(2px);
box-shadow: 0 3px 10px color-mix(in srgb, var(--accent) 35%, transparent);
}
.auth-register-panel {
padding: 0;
border: none;
background: transparent;
}
.auth-register-cancel {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
text-align: center;
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.65rem 1.25rem;
min-height: 2.6rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.95rem;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.btn-secondary:hover {
color: var(--text);
border-color: var(--muted);
background: var(--panel);
}
.account-header {
display: flex;
align-items: center;
@@ -1595,3 +1747,74 @@ code {
background: oklch(0.705 0.165 55 / 0.07);
}
}
/* Comments */
.commentable {
position: relative;
}
.commentable:hover {
background: var(--accent-soft);
}
.comment-anchor-btn {
position: absolute;
right: -1.5rem;
top: 0.25rem;
width: 1.2rem;
height: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--panel-strong);
color: var(--accent);
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.commentable:hover .comment-anchor-btn {
opacity: 1;
}
.comment-list {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel);
}
.comment-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.comment-item:last-child {
border-bottom: 0;
}
.comment-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: var(--muted);
}
.comment-meta strong {
color: var(--text);
}
.comment-body {
font-size: 0.9rem;
line-height: 1.5;
}

View File

@@ -22,14 +22,20 @@
<img src="/static/favicon.png" alt="" width="64" height="64" decoding="async" />
<span class="sr-only">Cairnquire</span>
</a>
<form class="site-search" action="/" method="get">
<input type="search" name="q" placeholder="Search..." aria-label="Search documents" />
<button type="submit" aria-label="Search">🔍</button>
<form class="site-search" action="/" method="get" role="search">
<svg class="site-search__icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" name="q" placeholder="Search documents…" aria-label="Search documents" />
</form>
<nav class="site-nav">
<a href="/">Docs</a>
<a href="/account">Account</a>
{{ if .WebEnabled }}<a href="/app/">Admin</a>{{ end }}
<a href="/help" aria-label="Help" title="Help">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</a>
<a href="/settings" aria-label="Settings" title="Settings">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</a>
<a href="/account" aria-label="Account" title="Account">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</a>
</nav>
</div>
</header>
@@ -80,6 +86,7 @@
<script src="/static/editor.js" defer></script>
<script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script>
<script src="/static/comments.js" defer></script>
</body>
</html>

View File

@@ -6,71 +6,94 @@
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p>
<h1>Sign in</h1>
<h1>Log In</h1>
</div>
<div class="auth-grid">
<form class="auth-form" data-auth-login>
<h2>Password</h2>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Password
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button type="submit">Sign in</button>
<p class="form-status" data-login-status></p>
</form>
<div class="auth-login-section" data-login-section>
<div class="auth-tabs">
<div class="auth-tab-list" role="tablist">
<button type="button" class="auth-tab is-active" role="tab" aria-selected="true" data-tab="passkey">Passkey</button>
<button type="button" class="auth-tab" role="tab" aria-selected="false" data-tab="password">Password</button>
</div>
<form class="auth-form" data-passkey-login>
<h2>Passkey</h2>
<label>
Email
<input type="email" name="email" autocomplete="username webauthn" required />
</label>
<button type="submit">Use passkey</button>
<p class="form-status" data-passkey-login-status></p>
</form>
</div>
<div class="auth-tab-panel is-active" role="tabpanel" data-tab-panel="passkey">
<form class="auth-form" data-passkey-login>
<label>
Email
<input type="email" name="email" autocomplete="username webauthn" required />
</label>
<button type="submit" class="btn-primary">Use passkey</button>
<p class="form-status" data-passkey-login-status></p>
</form>
</div>
<details class="auth-details">
<summary>Create an account</summary>
<div class="auth-grid">
<form class="auth-form" data-auth-register>
<h2>Password account</h2>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Display name
<input type="text" name="displayName" autocomplete="name" />
</label>
<label>
Password
<input type="password" name="password" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit">Register</button>
<p class="form-status" data-register-status></p>
</form>
<form class="auth-form" data-passkey-register>
<h2>Passkey account</h2>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Display name
<input type="text" name="displayName" autocomplete="name" />
</label>
<button type="submit">Create passkey</button>
<p class="form-status" data-passkey-register-status></p>
</form>
<div class="auth-tab-panel" role="tabpanel" data-tab-panel="password" hidden>
<form class="auth-form" data-auth-login>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Password
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button type="submit" class="btn-primary">Sign in</button>
<p class="form-status" data-login-status></p>
</form>
</div>
</div>
</details>
<div class="auth-register-cta">
<button type="button" class="btn-cta" data-register-toggle>Create an Account</button>
</div>
</div>
<div class="auth-register-panel" data-register-panel hidden>
<div class="auth-tabs">
<div class="auth-tab-list" role="tablist">
<button type="button" class="auth-tab is-active" role="tab" aria-selected="true" data-tab="passkey-register">Passkey</button>
<button type="button" class="auth-tab" role="tab" aria-selected="false" data-tab="password-register">Password</button>
</div>
<div class="auth-tab-panel is-active" role="tabpanel" data-tab-panel="passkey-register">
<form class="auth-form" data-passkey-register>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Display name
<input type="text" name="displayName" autocomplete="name" />
</label>
<button type="submit" class="btn-primary">Create passkey</button>
<p class="form-status" data-passkey-register-status></p>
</form>
</div>
<div class="auth-tab-panel" role="tabpanel" data-tab-panel="password-register" hidden>
<form class="auth-form" data-auth-register>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Display name
<input type="text" name="displayName" autocomplete="name" />
</label>
<label>
Password
<input type="password" name="password" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit" class="btn-primary">Register</button>
<p class="form-status" data-register-status></p>
</form>
</div>
</div>
<div class="auth-register-cancel">
<button type="button" class="btn-secondary" data-register-cancel>Cancel — Back to Log In</button>
</div>
</div>
</div>
</section>
{{ end }}