server auth
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@@ -61,6 +62,11 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
|
||||
syncRepo := sync.NewRepository(db.SQL())
|
||||
syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger)
|
||||
authRepo := auth.NewRepository(db.SQL())
|
||||
authService, err := auth.NewService(authRepo, cfg.Auth.PublicOrigin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build auth service: %w", err)
|
||||
}
|
||||
|
||||
handler, err := httpserver.New(httpserver.Dependencies{
|
||||
Config: cfg,
|
||||
@@ -71,6 +77,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
Hub: hub,
|
||||
SyncService: syncService,
|
||||
SyncRepo: syncRepo,
|
||||
Auth: authService,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build http handler: %w", err)
|
||||
|
||||
@@ -7,15 +7,19 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
const fileWatcherDebounce = 500 * time.Millisecond
|
||||
|
||||
type FileWatcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
rootDir string
|
||||
onChange func(path string)
|
||||
logger *slog.Logger
|
||||
debounce time.Duration
|
||||
}
|
||||
|
||||
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
||||
@@ -25,9 +29,10 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
||||
}
|
||||
|
||||
fw := &FileWatcher{
|
||||
watcher: watcher,
|
||||
rootDir: rootDir,
|
||||
logger: logger,
|
||||
watcher: watcher,
|
||||
rootDir: rootDir,
|
||||
logger: logger,
|
||||
debounce: fileWatcherDebounce,
|
||||
}
|
||||
|
||||
if err := fw.addWatches(); err != nil {
|
||||
@@ -39,19 +44,150 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) addWatches() error {
|
||||
return filepath.Walk(fw.rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
return filepath.WalkDir(fw.rootDir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := fw.watcher.Add(path); err != nil {
|
||||
fw.logger.Warn("watch directory", "path", path, "error", err)
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path != fw.rootDir && fw.shouldIgnorePath(path) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if err := fw.watcher.Add(path); err != nil {
|
||||
fw.logger.Warn("watch directory", "path", path, "error", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) addWatchIfDirectory(path string) {
|
||||
if fw.shouldIgnorePath(path) {
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
if err := fw.watcher.Add(path); err != nil {
|
||||
fw.logger.Warn("watch new directory", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) flushPending(pending map[string]struct{}) {
|
||||
if len(pending) == 0 || fw.onChange == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var changedPath string
|
||||
for path := range pending {
|
||||
changedPath = path
|
||||
break
|
||||
}
|
||||
clear(pending)
|
||||
fw.onChange(changedPath)
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) scheduleSync(path string, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
|
||||
pending[path] = struct{}{}
|
||||
|
||||
if *timer == nil {
|
||||
*timer = time.NewTimer(fw.debounce)
|
||||
*timerC = (*timer).C
|
||||
return
|
||||
}
|
||||
|
||||
if !(*timer).Stop() {
|
||||
select {
|
||||
case <-(*timer).C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
(*timer).Reset(fw.debounce)
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) stopTimer(timer *time.Timer) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) isMarkdownPath(path string) bool {
|
||||
return strings.EqualFold(filepath.Ext(path), ".md")
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) shouldIgnorePath(path string) bool {
|
||||
rel, err := filepath.Rel(fw.rootDir, path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if rel == "." {
|
||||
return false
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(part, ".") {
|
||||
return true
|
||||
}
|
||||
if isTempFileName(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTempFileName(name string) bool {
|
||||
if strings.HasSuffix(name, "~") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") {
|
||||
return true
|
||||
}
|
||||
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".swp", ".swo", ".tmp", ".temp", ".bak", ".orig", ".part", ".crdownload":
|
||||
return true
|
||||
}
|
||||
|
||||
switch name {
|
||||
case ".DS_Store", "Thumbs.db":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) isProcessableEvent(event fsnotify.Event) bool {
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
return false
|
||||
}
|
||||
if fw.shouldIgnorePath(event.Name) {
|
||||
return false
|
||||
}
|
||||
return fw.isMarkdownPath(event.Name)
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) handleEvent(event fsnotify.Event, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
|
||||
if event.Op&(fsnotify.Create|fsnotify.Rename) != 0 {
|
||||
fw.addWatchIfDirectory(event.Name)
|
||||
}
|
||||
if fw.isProcessableEvent(event) {
|
||||
fw.scheduleSync(event.Name, pending, timer, timerC)
|
||||
}
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
||||
fw.onChange = fn
|
||||
}
|
||||
@@ -59,24 +195,24 @@ func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
||||
func (fw *FileWatcher) Run(ctx context.Context) {
|
||||
defer fw.watcher.Close()
|
||||
|
||||
pending := make(map[string]struct{})
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
defer fw.stopTimer(timer)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timerC:
|
||||
fw.flushPending(pending)
|
||||
timer = nil
|
||||
timerC = nil
|
||||
case event, ok := <-fw.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if fw.shouldProcess(event) {
|
||||
if event.Op&fsnotify.Create == fsnotify.Create {
|
||||
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
||||
fw.watcher.Add(event.Name)
|
||||
}
|
||||
}
|
||||
if fw.onChange != nil {
|
||||
fw.onChange(event.Name)
|
||||
}
|
||||
}
|
||||
fw.handleEvent(event, pending, &timer, &timerC)
|
||||
case err, ok := <-fw.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
@@ -85,17 +221,3 @@ func (fw *FileWatcher) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) shouldProcess(event fsnotify.Event) bool {
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
return false
|
||||
}
|
||||
name := filepath.Base(event.Name)
|
||||
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "~") {
|
||||
return false
|
||||
}
|
||||
if filepath.Ext(event.Name) == ".md" || event.Op&fsnotify.Remove != 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
84
apps/server/internal/app/watcher_test.go
Normal file
84
apps/server/internal/app/watcher_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
func TestFileWatcherFiltersProcessableEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
watcher := &FileWatcher{rootDir: root, logger: slog.Default()}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
op fsnotify.Op
|
||||
want bool
|
||||
}{
|
||||
{name: "markdown write", path: "guide.md", op: fsnotify.Write, want: true},
|
||||
{name: "markdown create", path: "nested/guide.md", op: fsnotify.Create, want: true},
|
||||
{name: "case insensitive markdown", path: "guide.MD", op: fsnotify.Write, want: true},
|
||||
{name: "markdown delete", path: "guide.md", op: fsnotify.Remove, want: true},
|
||||
{name: "non markdown write", path: "logo.webp", op: fsnotify.Write, want: false},
|
||||
{name: "non markdown delete", path: "logo.webp", op: fsnotify.Remove, want: false},
|
||||
{name: "hidden markdown file", path: ".guide.md", op: fsnotify.Write, want: false},
|
||||
{name: "hidden directory", path: ".cache/guide.md", op: fsnotify.Write, want: false},
|
||||
{name: "backup file", path: "guide.md~", op: fsnotify.Write, want: false},
|
||||
{name: "vim swap", path: "guide.md.swp", op: fsnotify.Write, want: false},
|
||||
{name: "emacs lock file", path: "#guide.md#", op: fsnotify.Write, want: false},
|
||||
{name: "chmod only", path: "guide.md", op: fsnotify.Chmod, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
event := fsnotify.Event{Name: filepath.Join(root, tt.path), Op: tt.op}
|
||||
if got := watcher.isProcessableEvent(event); got != tt.want {
|
||||
t.Fatalf("isProcessableEvent() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileWatcherDebouncesPendingSyncs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
watcher := &FileWatcher{
|
||||
rootDir: root,
|
||||
logger: slog.Default(),
|
||||
debounce: 10 * time.Millisecond,
|
||||
}
|
||||
|
||||
var changed []string
|
||||
watcher.SetOnChange(func(path string) {
|
||||
changed = append(changed, path)
|
||||
})
|
||||
|
||||
pending := make(map[string]struct{})
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
defer watcher.stopTimer(timer)
|
||||
|
||||
watcher.scheduleSync(filepath.Join(root, "one.md"), pending, &timer, &timerC)
|
||||
watcher.scheduleSync(filepath.Join(root, "two.md"), pending, &timer, &timerC)
|
||||
|
||||
select {
|
||||
case <-timerC:
|
||||
watcher.flushPending(pending)
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
t.Fatal("debounce timer did not fire")
|
||||
}
|
||||
|
||||
if len(changed) != 1 {
|
||||
t.Fatalf("onChange calls = %d, want 1", len(changed))
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending changes = %d, want 0", len(pending))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user