Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,15 @@ GIT_REPO_ROOT=./git-repos
# If not set, all origins are allowed (development only)
# CORS_ORIGINS=https://webpass.example.com,https://webpass.pages.dev

# Session duration in minutes (optional)
# Default: 5, Valid range: 5-480 (5 minutes to 8 hours)
# Invalid values will use default and print a warning
# SESSION_DURATION_MINUTES=5
# Hard limit (optional)
# Default: 30, Valid range: 5-480 (5 minutes to 8 hours)
# Maximum time a session can last from login (regardless of activity)
# SESSION_HARDLIMIT_MINUTES=30

# Soft limit (optional)
# Default: 5, Valid range: 1-60 (1 minute to 1 hour)
# Detects browser close: session expires if no activity for this duration
# SESSION_SOFTLIMIT_MINUTES=5

# ============================================
# Cookie-based Authentication (httpOnly)
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ npm run typecheck
| `PORT` | HTTP listen port (default: `8080`) |
| `CORS_ORIGINS` | Comma-separated allowed origins |
| `GIT_REPO_ROOT` | Git repos directory (default: `/data/git-repos`) |
| `SESSION_DURATION_MINUTES` | JWT session expiry time in minutes (default: 5, valid range: 5-480) |
| `SESSION_HARDLIMIT_MINUTES` | JWT hard limit (max session time) in minutes (default: 30, range: 5-480) |
| `SESSION_SOFTLIMIT_MINUTES` | JWT soft limit (browser close detection) in minutes (default: 5, range: 1-60) |

## Database

Expand Down
48 changes: 48 additions & 0 deletions IMPROVEMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# WebPass Improvements

## High Priority

### 1. Backend: Structured Error Types
Replace string-based errors with typed error types for consistent API responses.
- [x] DONE - Created srv/errors.go with APIError type and error codes

### 2. Backend: Graceful Shutdown
Add `os.Signal` handling to close DB connections and HTTP listener cleanly.
- [x] DONE - Added signal handling in cmd/srv/main.go

### 3. Backend: Request Validation
Use validation library instead of manual JSON field checking.
- [ ] SKIPPED - Manual validation is already consistent; library adds complexity

## Medium Priority

### 5. Database: Add Indexes
Add indexes on `entries.path` column for query performance with large datasets.
- [x] DONE - Created migration 004-indexes-perf.sql with idx_entries_fingerprint_path and idx_entries_fingerprint

### 6. Database: Foreign Keys
Add FK constraint on `git_config.fingerprint` referencing users table.
- [x] ALREADY DONE - FK exists in 002-git-sync.sql

## Low Priority

### 10. Testing Coverage
Add unit tests for backend utilities and frontend edge cases.
- [x] DONE - Added srv/errors_test.go for APIError type tests

### 12. Frontend State: Preact Signals
Consider using Preact Signals for better state management.
- [ ] TODO - Current pub/sub pattern works well; needs discussion before changing

### 13. Accessibility: ARIA Labels
Add ARIA labels to icon-only buttons and custom components.
- [x] DONE - Added aria-label to password/notes toggle buttons in EntryDetail and OTPDisplay

## Next Step - Needs Discussion

### A1. Session: Refresh Tokens (IMPLEMENTED)
Implement refresh token mechanism for sessions longer than 5 minutes.
- [x] DONE - Implemented with DB columns login_time and last_activity:
- Hard limit: 30 min (configurable via SESSION_HARDLIMIT_MINUTES)
- Soft limit: 5 min (configurable via SESSION_SOFTLIMIT_MINUTES)
- Auto-rotate: Updates last_activity on each API call
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ See [`.env.example`](.env.example) for all available options with detailed comme
| `CORS_ORIGINS` | No | Comma-separated allowed origins |
| `PORT` | No | HTTP listen port (default: `8080`) |
| `GIT_REPO_ROOT`| No | Git repos directory (default: `/data/git-repos`) |
| `SESSION_DURATION_MINUTES` | No | JWT session expiry in minutes (default: 5, range: 5-480) |
| `SESSION_HARDLIMIT_MINUTES` | No | JWT hard limit (max session time) in minutes (default: 30, range: 5-480) |
| `SESSION_SOFTLIMIT_MINUTES` | No | JWT soft limit (browser close detection) in minutes (default: 5, range: 1-60) |
| `DISABLE_FRONTEND` | No | Disable frontend (`1` or `true`) |
| `BCRYPT_COST` | No | Password hashing cost factor (default: 12, range: 10-15) |

Expand Down
86 changes: 73 additions & 13 deletions cmd/srv/main.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package main

import (
"context"
"crypto/rand"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"runtime"
"strconv"
"syscall"
"time"

"srv.exe.dev/srv"
)
Expand Down Expand Up @@ -70,19 +76,35 @@ func run() error {
listenAddr = ":" + port
}

// Session duration (default 5 minutes, range: 5-480)
sessionDurationMin := 5 // default
if durationStr := os.Getenv("SESSION_DURATION_MINUTES"); durationStr != "" {
if duration, err := strconv.Atoi(durationStr); err == nil {
if duration >= 5 && duration <= 480 {
sessionDurationMin = duration
} else if duration < 5 {
fmt.Printf("WARNING: SESSION_DURATION_MINUTES=%d too low, using minimum: 5\n", duration)
// Hard limit (default 30 minutes, range: 5-480)
hardLimitMin := 30 // default
if hardLimitStr := os.Getenv("SESSION_HARDLIMIT_MINUTES"); hardLimitStr != "" {
if hardLimit, err := strconv.Atoi(hardLimitStr); err == nil {
if hardLimit >= 5 && hardLimit <= 480 {
hardLimitMin = hardLimit
} else if hardLimit < 5 {
fmt.Printf("WARNING: SESSION_HARDLIMIT_MINUTES=%d too low, using minimum: 5\n", hardLimit)
} else {
fmt.Printf("WARNING: SESSION_DURATION_MINUTES=%d too high, using maximum: 480\n", duration)
fmt.Printf("WARNING: SESSION_HARDLIMIT_MINUTES=%d too high, using maximum: 480\n", hardLimit)
}
} else {
fmt.Printf("WARNING: Invalid SESSION_DURATION_MINUTES=%s, using default: 5\n", durationStr)
fmt.Printf("WARNING: Invalid SESSION_HARDLIMIT_MINUTES=%s, using default: 30\n", hardLimitStr)
}
}

// Soft limit (default 5 minutes, range: 1-60)
softLimitMin := 5 // default
if softLimitStr := os.Getenv("SESSION_SOFTLIMIT_MINUTES"); softLimitStr != "" {
if softLimit, err := strconv.Atoi(softLimitStr); err == nil {
if softLimit >= 1 && softLimit <= 60 {
softLimitMin = softLimit
} else if softLimit < 1 {
fmt.Printf("WARNING: SESSION_SOFTLIMIT_MINUTES=%d too low, using minimum: 1\n", softLimit)
} else {
fmt.Printf("WARNING: SESSION_SOFTLIMIT_MINUTES=%d too high, using maximum: 60\n", softLimit)
}
} else {
fmt.Printf("WARNING: Invalid SESSION_SOFTLIMIT_MINUTES=%s, using default: 5\n", softLimitStr)
}
}

Expand All @@ -94,7 +116,8 @@ func run() error {
fmt.Printf(" Disable Frontend:%s\n", disableFrontend)
fmt.Printf(" Git Repo Root: %s\n", gitRepoRoot)
fmt.Printf(" CORS Origins: %s\n", corsOrigins)
fmt.Printf(" Session Duration:%d minutes\n", sessionDurationMin)
fmt.Printf(" Hard Limit: %d minutes (SESSION_HARDLIMIT_MINUTES)\n", hardLimitMin)
fmt.Printf(" Soft Limit: %d minutes (SESSION_SOFTLIMIT_MINUTES)\n", softLimitMin)
fmt.Println()

jwtKey := make([]byte, 32)
Expand All @@ -106,7 +129,7 @@ func run() error {
}
}

server, err := srv.New(dbPath, jwtKey, sessionDurationMin)
server, err := srv.New(dbPath, jwtKey, hardLimitMin, softLimitMin)
if err != nil {
return fmt.Errorf("create server: %w", err)
}
Expand All @@ -123,5 +146,42 @@ func run() error {
}
}

return server.Serve(listenAddr)
// Create HTTP server
httpServer := &http.Server{
Addr: listenAddr,
Handler: server.Handler(),
}

// Start server in goroutine
go func() {
slog.Info("starting server", "addr", listenAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
}
}()

// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit

slog.Info("shutting down server...")

// Give outstanding requests 30 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := httpServer.Shutdown(ctx); err != nil {
slog.Error("server shutdown error", "error", err)
}

// Close database connection
if err := server.CloseDB(); err != nil {
slog.Error("database close error", "error", err)
} else {
slog.Info("database connection closed")
}

slog.Info("server stopped")
return nil
}
2 changes: 1 addition & 1 deletion db/dbgen/db.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion db/dbgen/git.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion db/dbgen/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion db/dbgen/visitors.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 72 additions & 8 deletions db/dbgen/webpass.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading