Compare commits
4 Commits
d4318c71b9
...
04534d6f98
| Author | SHA1 | Date |
|---|---|---|
|
|
04534d6f98 | 3 days ago |
|
|
4890660c72 | 3 days ago |
|
|
9b5f1d08d4 | 3 days ago |
|
|
18d81da455 | 3 days ago |
34 changed files with 2796 additions and 15 deletions
@ -0,0 +1,122 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"errors" |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
"strconv" |
||||
|
"time" |
||||
|
|
||||
|
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" |
||||
|
"github.com/go-chi/chi/v5" |
||||
|
) |
||||
|
|
||||
|
type Handler struct { |
||||
|
store *Store |
||||
|
sessionTTL time.Duration |
||||
|
} |
||||
|
|
||||
|
func NewHandler(store *Store, sessionTTL time.Duration) *Handler { |
||||
|
return &Handler{store: store, sessionTTL: sessionTTL} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) LoginPage(w http.ResponseWriter, _ *http.Request) { |
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
_ = loginTemplate.ExecuteTemplate(w, webtemplates.Login, nil) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { |
||||
|
if err := r.ParseForm(); err != nil { |
||||
|
http.Error(w, "invalid form", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
user, err := h.store.Authenticate(r.FormValue("username"), r.FormValue("password")) |
||||
|
if errors.Is(err, ErrInvalidCredentials) { |
||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized) |
||||
|
return |
||||
|
} |
||||
|
if err != nil { |
||||
|
http.Error(w, "login failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
token, err := h.store.CreateSession(user.ID, h.sessionTTL) |
||||
|
if err != nil { |
||||
|
http.Error(w, "create session failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.SetCookie(w, &http.Cookie{ |
||||
|
Name: sessionCookieName, |
||||
|
Value: token, |
||||
|
Path: "/", |
||||
|
Expires: time.Now().UTC().Add(h.sessionTTL), |
||||
|
HttpOnly: true, |
||||
|
SameSite: http.SameSiteLaxMode, |
||||
|
}) |
||||
|
http.Redirect(w, r, "/", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { |
||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil { |
||||
|
_ = h.store.DeleteSession(cookie.Value) |
||||
|
} |
||||
|
|
||||
|
http.SetCookie(w, &http.Cookie{ |
||||
|
Name: sessionCookieName, |
||||
|
Value: "", |
||||
|
Path: "/", |
||||
|
Expires: time.Unix(0, 0), |
||||
|
MaxAge: -1, |
||||
|
HttpOnly: true, |
||||
|
SameSite: http.SameSiteLaxMode, |
||||
|
}) |
||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) UsersPage(w http.ResponseWriter, _ *http.Request) { |
||||
|
users, err := h.store.ListUsers() |
||||
|
if err != nil { |
||||
|
http.Error(w, "list users failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
_ = adminUsersTemplate.ExecuteTemplate(w, webtemplates.AdminUsers, struct { |
||||
|
Users []User |
||||
|
}{Users: users}) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { |
||||
|
if err := r.ParseForm(); err != nil { |
||||
|
http.Error(w, "invalid form", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if err := h.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("is_admin") == "on"); err != nil { |
||||
|
http.Error(w, "create user failed", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { |
||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
||||
|
if err != nil { |
||||
|
http.Error(w, "invalid user id", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if err := h.store.DeleteUser(id); err != nil { |
||||
|
http.Error(w, "delete user failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
var loginTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.Login)) |
||||
|
|
||||
|
var adminUsersTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.AdminUsers)) |
||||
@ -0,0 +1,192 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"net/url" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func TestLoginPageRenders(t *testing.T) { |
||||
|
handler, _, _ := newTestHandler(t) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/login", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
handler.LoginPage(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected 200, got %d", response.Code) |
||||
|
} |
||||
|
if body := response.Body.String(); !strings.Contains(body, `<form`) || !strings.Contains(body, `name="username"`) { |
||||
|
t.Fatalf("login page missing expected form fields: %s", body) |
||||
|
} |
||||
|
|
||||
|
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(readTemplateFile(t, "login.html")); got != want { |
||||
|
t.Fatalf("login page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestLoginValidCredentialsSetSessionCookieAndRedirect(t *testing.T) { |
||||
|
handler, store, _ := newTestHandler(t) |
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
form := url.Values{"username": {"alice"}, "password": {"secret"}} |
||||
|
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) |
||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
handler.Login(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusSeeOther { |
||||
|
t.Fatalf("expected 303, got %d", response.Code) |
||||
|
} |
||||
|
if location := response.Header().Get("Location"); location != "/" { |
||||
|
t.Fatalf("expected redirect to /, got %q", location) |
||||
|
} |
||||
|
|
||||
|
cookie := findCookie(response.Result().Cookies(), sessionCookieName) |
||||
|
if cookie == nil || cookie.Value == "" { |
||||
|
t.Fatalf("expected session cookie, got %#v", response.Result().Cookies()) |
||||
|
} |
||||
|
if _, err := store.GetSession(cookie.Value); err != nil { |
||||
|
t.Fatalf("session cookie was not persisted: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestLoginInvalidCredentialsReturnsUnauthorized(t *testing.T) { |
||||
|
handler, store, _ := newTestHandler(t) |
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
form := url.Values{"username": {"alice"}, "password": {"wrong"}} |
||||
|
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) |
||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
handler.Login(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusUnauthorized { |
||||
|
t.Fatalf("expected 401, got %d", response.Code) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestAdminUsersRequiresAdmin(t *testing.T) { |
||||
|
handler, store, mux := newTestHandler(t) |
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create regular user: %v", err) |
||||
|
} |
||||
|
if err := store.CreateUser("admin", "secret", true); err != nil { |
||||
|
t.Fatalf("create admin user: %v", err) |
||||
|
} |
||||
|
|
||||
|
mux.Handle("/admin/users", RequireAuth(store)(RequireAdmin(http.HandlerFunc(handler.UsersPage)))) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
mux.ServeHTTP(response, request) |
||||
|
if response.Code != http.StatusFound { |
||||
|
t.Fatalf("expected unauthenticated redirect, got %d", response.Code) |
||||
|
} |
||||
|
if location := response.Header().Get("Location"); location != "/login" { |
||||
|
t.Fatalf("expected redirect to /login, got %q", location) |
||||
|
} |
||||
|
|
||||
|
regularCookie := sessionCookie(t, store, "alice", "secret") |
||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
||||
|
request.AddCookie(regularCookie) |
||||
|
response = httptest.NewRecorder() |
||||
|
mux.ServeHTTP(response, request) |
||||
|
if response.Code != http.StatusForbidden { |
||||
|
t.Fatalf("expected regular user forbidden, got %d", response.Code) |
||||
|
} |
||||
|
|
||||
|
adminCookie := sessionCookie(t, store, "admin", "secret") |
||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
||||
|
request.AddCookie(adminCookie) |
||||
|
response = httptest.NewRecorder() |
||||
|
mux.ServeHTTP(response, request) |
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected admin page, got %d", response.Code) |
||||
|
} |
||||
|
if !strings.Contains(response.Body.String(), "admin") { |
||||
|
t.Fatalf("admin page missing user list: %s", response.Body.String()) |
||||
|
} |
||||
|
|
||||
|
users, err := store.ListUsers() |
||||
|
if err != nil { |
||||
|
t.Fatalf("list users: %v", err) |
||||
|
} |
||||
|
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(renderTemplateFile(t, "admin_users.html", struct { |
||||
|
Users []User |
||||
|
}{Users: users})); got != want { |
||||
|
t.Fatalf("admin users page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func newTestHandler(t *testing.T) (*Handler, *Store, *http.ServeMux) { |
||||
|
t.Helper() |
||||
|
|
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
handler := NewHandler(store, time.Hour) |
||||
|
|
||||
|
return handler, store, http.NewServeMux() |
||||
|
} |
||||
|
|
||||
|
func sessionCookie(t *testing.T, store *Store, username, password string) *http.Cookie { |
||||
|
t.Helper() |
||||
|
|
||||
|
user, err := store.Authenticate(username, password) |
||||
|
if err != nil { |
||||
|
t.Fatalf("authenticate %s: %v", username, err) |
||||
|
} |
||||
|
token, err := store.CreateSession(user.ID, time.Hour) |
||||
|
if err != nil { |
||||
|
t.Fatalf("create session: %v", err) |
||||
|
} |
||||
|
return &http.Cookie{Name: sessionCookieName, Value: token} |
||||
|
} |
||||
|
|
||||
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie { |
||||
|
for _, cookie := range cookies { |
||||
|
if cookie.Name == name { |
||||
|
return cookie |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func readTemplateFile(t *testing.T, name string) string { |
||||
|
t.Helper() |
||||
|
|
||||
|
content, err := os.ReadFile(filepath.Join("..", "web", "templates", name)) |
||||
|
if err != nil { |
||||
|
t.Fatalf("read template %s: %v", name, err) |
||||
|
} |
||||
|
return string(content) |
||||
|
} |
||||
|
|
||||
|
func renderTemplateFile(t *testing.T, name string, data any) string { |
||||
|
t.Helper() |
||||
|
|
||||
|
tmpl, err := template.ParseFiles(filepath.Join("..", "web", "templates", name)) |
||||
|
if err != nil { |
||||
|
t.Fatalf("parse template %s: %v", name, err) |
||||
|
} |
||||
|
|
||||
|
var output bytes.Buffer |
||||
|
if err := tmpl.ExecuteTemplate(&output, name, data); err != nil { |
||||
|
t.Fatalf("execute template %s: %v", name, err) |
||||
|
} |
||||
|
return output.String() |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"net/http" |
||||
|
) |
||||
|
|
||||
|
const sessionCookieName = "session" |
||||
|
|
||||
|
type contextKey string |
||||
|
|
||||
|
const userContextKey contextKey = "user" |
||||
|
|
||||
|
func RequireAuth(store *Store) func(http.Handler) http.Handler { |
||||
|
return func(next http.Handler) http.Handler { |
||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
|
cookie, err := r.Cookie(sessionCookieName) |
||||
|
if err != nil || cookie.Value == "" { |
||||
|
http.Redirect(w, r, "/login", http.StatusFound) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
session, err := store.GetSession(cookie.Value) |
||||
|
if err != nil { |
||||
|
http.Redirect(w, r, "/login", http.StatusFound) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
ctx := context.WithValue(r.Context(), userContextKey, session.User) |
||||
|
next.ServeHTTP(w, r.WithContext(ctx)) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func RequireAdmin(next http.Handler) http.Handler { |
||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
|
user, ok := UserFromContext(r.Context()) |
||||
|
if !ok || !user.IsAdmin { |
||||
|
http.Error(w, "forbidden", http.StatusForbidden) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
next.ServeHTTP(w, r) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func UserFromContext(ctx context.Context) (*User, bool) { |
||||
|
user, ok := ctx.Value(userContextKey).(*User) |
||||
|
return user, ok |
||||
|
} |
||||
@ -0,0 +1,208 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"crypto/rand" |
||||
|
"database/sql" |
||||
|
"encoding/base64" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"golang.org/x/crypto/bcrypt" |
||||
|
) |
||||
|
|
||||
|
var ErrInvalidCredentials = errors.New("invalid credentials") |
||||
|
var ErrSessionNotFound = errors.New("session not found") |
||||
|
|
||||
|
type Store struct { |
||||
|
db *sql.DB |
||||
|
} |
||||
|
|
||||
|
type User struct { |
||||
|
ID int64 |
||||
|
Username string |
||||
|
IsAdmin bool |
||||
|
} |
||||
|
|
||||
|
type Session struct { |
||||
|
Token string |
||||
|
UserID int64 |
||||
|
ExpiresAt time.Time |
||||
|
User *User |
||||
|
} |
||||
|
|
||||
|
func NewStore(database *sql.DB) *Store { |
||||
|
return &Store{db: database} |
||||
|
} |
||||
|
|
||||
|
func (s *Store) EnsureAdmin(username, password string) error { |
||||
|
username = strings.TrimSpace(username) |
||||
|
if username == "" || password == "" { |
||||
|
return fmt.Errorf("admin username and password are required") |
||||
|
} |
||||
|
|
||||
|
var count int |
||||
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { |
||||
|
return fmt.Errorf("count users: %w", err) |
||||
|
} |
||||
|
if count > 0 { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
return s.CreateUser(username, password, true) |
||||
|
} |
||||
|
|
||||
|
func (s *Store) CreateUser(username, password string, isAdmin bool) error { |
||||
|
username = strings.TrimSpace(username) |
||||
|
if username == "" { |
||||
|
return fmt.Errorf("username is required") |
||||
|
} |
||||
|
if password == "" { |
||||
|
return fmt.Errorf("password is required") |
||||
|
} |
||||
|
|
||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("hash password: %w", err) |
||||
|
} |
||||
|
|
||||
|
adminValue := 0 |
||||
|
if isAdmin { |
||||
|
adminValue = 1 |
||||
|
} |
||||
|
|
||||
|
if _, err := s.db.Exec( |
||||
|
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`, |
||||
|
username, |
||||
|
string(hash), |
||||
|
adminValue, |
||||
|
); err != nil { |
||||
|
return fmt.Errorf("create user: %w", err) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) DeleteUser(id int64) error { |
||||
|
if _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil { |
||||
|
return fmt.Errorf("delete user: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) ListUsers() ([]User, error) { |
||||
|
rows, err := s.db.Query(`SELECT id, username, is_admin FROM users ORDER BY username`) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("list users: %w", err) |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
var users []User |
||||
|
for rows.Next() { |
||||
|
var user User |
||||
|
var isAdmin int |
||||
|
if err := rows.Scan(&user.ID, &user.Username, &isAdmin); err != nil { |
||||
|
return nil, fmt.Errorf("scan user: %w", err) |
||||
|
} |
||||
|
user.IsAdmin = isAdmin != 0 |
||||
|
users = append(users, user) |
||||
|
} |
||||
|
if err := rows.Err(); err != nil { |
||||
|
return nil, fmt.Errorf("iterate users: %w", err) |
||||
|
} |
||||
|
|
||||
|
return users, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) Authenticate(username, password string) (*User, error) { |
||||
|
var user User |
||||
|
var hash string |
||||
|
var isAdmin int |
||||
|
err := s.db.QueryRow( |
||||
|
`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`, |
||||
|
strings.TrimSpace(username), |
||||
|
).Scan(&user.ID, &user.Username, &hash, &isAdmin) |
||||
|
if errors.Is(err, sql.ErrNoRows) { |
||||
|
return nil, ErrInvalidCredentials |
||||
|
} |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("load user: %w", err) |
||||
|
} |
||||
|
|
||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { |
||||
|
return nil, ErrInvalidCredentials |
||||
|
} |
||||
|
|
||||
|
user.IsAdmin = isAdmin != 0 |
||||
|
return &user, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) CreateSession(userID int64, ttl time.Duration) (string, error) { |
||||
|
tokenBytes := make([]byte, 32) |
||||
|
if _, err := rand.Read(tokenBytes); err != nil { |
||||
|
return "", fmt.Errorf("generate session token: %w", err) |
||||
|
} |
||||
|
token := base64.RawURLEncoding.EncodeToString(tokenBytes) |
||||
|
expiresAt := time.Now().UTC().Add(ttl) |
||||
|
|
||||
|
if _, err := s.db.Exec( |
||||
|
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, |
||||
|
token, |
||||
|
userID, |
||||
|
expiresAt.Format(time.RFC3339Nano), |
||||
|
); err != nil { |
||||
|
return "", fmt.Errorf("create session: %w", err) |
||||
|
} |
||||
|
|
||||
|
return token, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) GetSession(token string) (*Session, error) { |
||||
|
var session Session |
||||
|
var expiresAt string |
||||
|
var user User |
||||
|
var isAdmin int |
||||
|
err := s.db.QueryRow( |
||||
|
`SELECT s.token, s.user_id, s.expires_at, u.username, u.is_admin |
||||
|
FROM sessions s |
||||
|
JOIN users u ON u.id = s.user_id |
||||
|
WHERE s.token = ?`, |
||||
|
token, |
||||
|
).Scan(&session.Token, &session.UserID, &expiresAt, &user.Username, &isAdmin) |
||||
|
if errors.Is(err, sql.ErrNoRows) { |
||||
|
return nil, ErrSessionNotFound |
||||
|
} |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("load session: %w", err) |
||||
|
} |
||||
|
|
||||
|
parsedExpiresAt, err := time.Parse(time.RFC3339Nano, expiresAt) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("parse session expiry: %w", err) |
||||
|
} |
||||
|
if !parsedExpiresAt.After(time.Now().UTC()) { |
||||
|
_ = s.DeleteSession(token) |
||||
|
return nil, ErrSessionNotFound |
||||
|
} |
||||
|
|
||||
|
user.ID = session.UserID |
||||
|
user.IsAdmin = isAdmin != 0 |
||||
|
session.ExpiresAt = parsedExpiresAt |
||||
|
session.User = &user |
||||
|
return &session, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) DeleteSession(token string) error { |
||||
|
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token); err != nil { |
||||
|
return fmt.Errorf("delete session: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) PurgeExpiredSessions() error { |
||||
|
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { |
||||
|
return fmt.Errorf("purge expired sessions: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
@ -0,0 +1,101 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"database/sql" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/domagojzecevic/cammonitor/internal/db" |
||||
|
) |
||||
|
|
||||
|
func TestStoreAuthenticateHappyPathAndWrongPassword(t *testing.T) { |
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
|
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
user, err := store.Authenticate("alice", "secret") |
||||
|
if err != nil { |
||||
|
t.Fatalf("authenticate valid user: %v", err) |
||||
|
} |
||||
|
if user.Username != "alice" { |
||||
|
t.Fatalf("expected alice, got %q", user.Username) |
||||
|
} |
||||
|
if user.IsAdmin { |
||||
|
t.Fatal("expected regular user") |
||||
|
} |
||||
|
|
||||
|
if _, err := store.Authenticate("alice", "wrong"); err == nil { |
||||
|
t.Fatal("expected wrong password to fail") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestStoreExpiredSessionIsRejected(t *testing.T) { |
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
|
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
user, err := store.Authenticate("alice", "secret") |
||||
|
if err != nil { |
||||
|
t.Fatalf("authenticate: %v", err) |
||||
|
} |
||||
|
|
||||
|
token, err := store.CreateSession(user.ID, -time.Minute) |
||||
|
if err != nil { |
||||
|
t.Fatalf("create session: %v", err) |
||||
|
} |
||||
|
|
||||
|
if _, err := store.GetSession(token); err == nil { |
||||
|
t.Fatal("expected expired session to fail") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestEnsureAdminCreatesFirstRunAdminOnlyWhenEmpty(t *testing.T) { |
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
|
||||
|
if err := store.EnsureAdmin("admin", "secret"); err != nil { |
||||
|
t.Fatalf("ensure admin: %v", err) |
||||
|
} |
||||
|
|
||||
|
users, err := store.ListUsers() |
||||
|
if err != nil { |
||||
|
t.Fatalf("list users: %v", err) |
||||
|
} |
||||
|
if len(users) != 1 || users[0].Username != "admin" || !users[0].IsAdmin { |
||||
|
t.Fatalf("unexpected users after bootstrap: %#v", users) |
||||
|
} |
||||
|
|
||||
|
if err := store.EnsureAdmin("other", "secret"); err != nil { |
||||
|
t.Fatalf("ensure admin second run: %v", err) |
||||
|
} |
||||
|
|
||||
|
users, err = store.ListUsers() |
||||
|
if err != nil { |
||||
|
t.Fatalf("list users second run: %v", err) |
||||
|
} |
||||
|
if len(users) != 1 { |
||||
|
t.Fatalf("expected no second bootstrap user, got %d", len(users)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func openTestDB(t *testing.T) *sql.DB { |
||||
|
t.Helper() |
||||
|
|
||||
|
database, err := db.Open(t.TempDir() + "/test.db") |
||||
|
if err != nil { |
||||
|
t.Fatalf("open database: %v", err) |
||||
|
} |
||||
|
t.Cleanup(func() { |
||||
|
if err := database.Close(); err != nil { |
||||
|
t.Fatalf("close database: %v", err) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
return database |
||||
|
} |
||||
@ -0,0 +1,104 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"sort" |
||||
|
"sync" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
type Index struct { |
||||
|
root string |
||||
|
interval time.Duration |
||||
|
|
||||
|
mu sync.RWMutex |
||||
|
days map[string]DayEntry |
||||
|
|
||||
|
done chan struct{} |
||||
|
once sync.Once |
||||
|
} |
||||
|
|
||||
|
func NewIndex(root string, interval time.Duration) *Index { |
||||
|
index := &Index{ |
||||
|
root: root, |
||||
|
interval: interval, |
||||
|
days: make(map[string]DayEntry), |
||||
|
done: make(chan struct{}), |
||||
|
} |
||||
|
index.rescan() |
||||
|
|
||||
|
if interval > 0 { |
||||
|
go index.run() |
||||
|
} |
||||
|
|
||||
|
return index |
||||
|
} |
||||
|
|
||||
|
func (i *Index) DayList() []string { |
||||
|
i.mu.RLock() |
||||
|
defer i.mu.RUnlock() |
||||
|
|
||||
|
days := make([]string, 0, len(i.days)) |
||||
|
for day := range i.days { |
||||
|
days = append(days, day) |
||||
|
} |
||||
|
sort.Sort(sort.Reverse(sort.StringSlice(days))) |
||||
|
return days |
||||
|
} |
||||
|
|
||||
|
func (i *Index) Day(date string) (*DayEntry, bool) { |
||||
|
i.mu.RLock() |
||||
|
defer i.mu.RUnlock() |
||||
|
|
||||
|
entry, ok := i.days[date] |
||||
|
if !ok { |
||||
|
return nil, false |
||||
|
} |
||||
|
|
||||
|
copied := copyDayEntry(entry) |
||||
|
return &copied, true |
||||
|
} |
||||
|
|
||||
|
func (i *Index) Close() { |
||||
|
i.once.Do(func() { |
||||
|
close(i.done) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func (i *Index) run() { |
||||
|
ticker := time.NewTicker(i.interval) |
||||
|
defer ticker.Stop() |
||||
|
|
||||
|
for { |
||||
|
select { |
||||
|
case <-ticker.C: |
||||
|
i.rescan() |
||||
|
case <-i.done: |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (i *Index) rescan() { |
||||
|
days, err := Scan(i.root) |
||||
|
if err != nil { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
next := make(map[string]DayEntry, len(days)) |
||||
|
for _, day := range days { |
||||
|
next[day.Date] = copyDayEntry(day) |
||||
|
} |
||||
|
|
||||
|
i.mu.Lock() |
||||
|
i.days = next |
||||
|
i.mu.Unlock() |
||||
|
} |
||||
|
|
||||
|
func copyDayEntry(entry DayEntry) DayEntry { |
||||
|
copied := DayEntry{ |
||||
|
Date: entry.Date, |
||||
|
Images: append([]ImageFile(nil), entry.Images...), |
||||
|
Videos: append([]VideoFile(nil), entry.Videos...), |
||||
|
} |
||||
|
return copied |
||||
|
} |
||||
@ -0,0 +1,96 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"sync" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func TestIndexListsDaysNewestFirstAndReturnsCopies(t *testing.T) { |
||||
|
root := filepath.Join("..", "..", "testdata", "footage") |
||||
|
|
||||
|
index := NewIndex(root, time.Hour) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
days := index.DayList() |
||||
|
if len(days) != 2 { |
||||
|
t.Fatalf("expected 2 days, got %d", len(days)) |
||||
|
} |
||||
|
if days[0] != "20260102" || days[1] != "20260101" { |
||||
|
t.Fatalf("expected days sorted newest first, got %#v", days) |
||||
|
} |
||||
|
|
||||
|
entry, ok := index.Day("20260101") |
||||
|
if !ok { |
||||
|
t.Fatalf("expected day 20260101") |
||||
|
} |
||||
|
entry.Images = nil |
||||
|
|
||||
|
entryAgain, ok := index.Day("20260101") |
||||
|
if !ok { |
||||
|
t.Fatalf("expected day 20260101 on second lookup") |
||||
|
} |
||||
|
if len(entryAgain.Images) != 2 { |
||||
|
t.Fatalf("expected index day copy to preserve images, got %d", len(entryAgain.Images)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestIndexPeriodicRescan(t *testing.T) { |
||||
|
root := t.TempDir() |
||||
|
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg")) |
||||
|
|
||||
|
index := NewIndex(root, 10*time.Millisecond) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
if days := index.DayList(); len(days) != 1 || days[0] != "20260101" { |
||||
|
t.Fatalf("expected initial day 20260101, got %#v", days) |
||||
|
} |
||||
|
|
||||
|
writeFixtureFile(t, root, filepath.Join("20260102", "record", "A260102_130000_130015.265")) |
||||
|
|
||||
|
deadline := time.Now().Add(time.Second) |
||||
|
for time.Now().Before(deadline) { |
||||
|
days := index.DayList() |
||||
|
if len(days) == 2 && days[0] == "20260102" && days[1] == "20260101" { |
||||
|
return |
||||
|
} |
||||
|
time.Sleep(10 * time.Millisecond) |
||||
|
} |
||||
|
|
||||
|
t.Fatalf("timed out waiting for rescan to include new day, got %#v", index.DayList()) |
||||
|
} |
||||
|
|
||||
|
func TestIndexConcurrentAccessDuringRescan(t *testing.T) { |
||||
|
root := t.TempDir() |
||||
|
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg")) |
||||
|
|
||||
|
index := NewIndex(root, time.Millisecond) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
var wg sync.WaitGroup |
||||
|
for worker := 0; worker < 8; worker++ { |
||||
|
wg.Add(1) |
||||
|
go func() { |
||||
|
defer wg.Done() |
||||
|
for i := 0; i < 100; i++ { |
||||
|
_ = index.DayList() |
||||
|
_, _ = index.Day("20260101") |
||||
|
} |
||||
|
}() |
||||
|
} |
||||
|
wg.Wait() |
||||
|
} |
||||
|
|
||||
|
func writeFixtureFile(t *testing.T, root, relPath string) { |
||||
|
t.Helper() |
||||
|
|
||||
|
path := filepath.Join(root, relPath) |
||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
||||
|
t.Fatalf("create fixture dir: %v", err) |
||||
|
} |
||||
|
if err := os.WriteFile(path, []byte("fixture"), 0o644); err != nil { |
||||
|
t.Fatalf("write fixture file: %v", err) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,212 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"regexp" |
||||
|
"sort" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
var ( |
||||
|
imageFilenamePattern = regexp.MustCompile(`^A(\d{6})(\d{6})\d+\.jpg$`) |
||||
|
videoFilenamePattern = regexp.MustCompile(`^A(\d{6})_(\d{6})_(\d{6})\.265$`) |
||||
|
) |
||||
|
|
||||
|
type DayEntry struct { |
||||
|
Date string |
||||
|
Images []ImageFile |
||||
|
Videos []VideoFile |
||||
|
} |
||||
|
|
||||
|
type ImageFile struct { |
||||
|
RelPath string |
||||
|
Filename string |
||||
|
Timestamp time.Time |
||||
|
} |
||||
|
|
||||
|
type VideoFile struct { |
||||
|
RelPath string |
||||
|
Filename string |
||||
|
StartTime time.Time |
||||
|
EndTime time.Time |
||||
|
Duration time.Duration |
||||
|
} |
||||
|
|
||||
|
func Scan(root string) ([]DayEntry, error) { |
||||
|
dayDirs, err := os.ReadDir(root) |
||||
|
if err != nil { |
||||
|
if os.IsNotExist(err) { |
||||
|
return nil, nil |
||||
|
} |
||||
|
return nil, fmt.Errorf("read footage root: %w", err) |
||||
|
} |
||||
|
|
||||
|
days := make([]DayEntry, 0, len(dayDirs)) |
||||
|
for _, dayDir := range dayDirs { |
||||
|
if !dayDir.IsDir() { |
||||
|
continue |
||||
|
} |
||||
|
if _, err := time.ParseInLocation("20060102", dayDir.Name(), time.Local); err != nil { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
entry := DayEntry{Date: dayDir.Name()} |
||||
|
dayPath := filepath.Join(root, dayDir.Name()) |
||||
|
|
||||
|
images, err := scanImages(root, dayPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
entry.Images = images |
||||
|
|
||||
|
videos, err := scanVideos(root, dayPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
entry.Videos = videos |
||||
|
|
||||
|
days = append(days, entry) |
||||
|
} |
||||
|
|
||||
|
sort.Slice(days, func(i, j int) bool { |
||||
|
return days[i].Date > days[j].Date |
||||
|
}) |
||||
|
|
||||
|
return days, nil |
||||
|
} |
||||
|
|
||||
|
func scanImages(root, dayPath string) ([]ImageFile, error) { |
||||
|
imageDir := filepath.Join(dayPath, "images") |
||||
|
entries, err := os.ReadDir(imageDir) |
||||
|
if err != nil { |
||||
|
if os.IsNotExist(err) { |
||||
|
return nil, nil |
||||
|
} |
||||
|
return nil, fmt.Errorf("read image dir %s: %w", imageDir, err) |
||||
|
} |
||||
|
|
||||
|
images := make([]ImageFile, 0, len(entries)) |
||||
|
for _, entry := range entries { |
||||
|
if entry.IsDir() { |
||||
|
continue |
||||
|
} |
||||
|
info, err := entry.Info() |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("read image info %s: %w", entry.Name(), err) |
||||
|
} |
||||
|
if !info.Mode().Type().IsRegular() { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
image, err := parseImageFilename(entry.Name()) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
image.RelPath = mustRel(root, filepath.Join(imageDir, entry.Name())) |
||||
|
images = append(images, image) |
||||
|
} |
||||
|
|
||||
|
sort.Slice(images, func(i, j int) bool { |
||||
|
if images[i].Timestamp.Equal(images[j].Timestamp) { |
||||
|
return images[i].Filename < images[j].Filename |
||||
|
} |
||||
|
return images[i].Timestamp.Before(images[j].Timestamp) |
||||
|
}) |
||||
|
|
||||
|
return images, nil |
||||
|
} |
||||
|
|
||||
|
func scanVideos(root, dayPath string) ([]VideoFile, error) { |
||||
|
recordDir := filepath.Join(dayPath, "record") |
||||
|
entries, err := os.ReadDir(recordDir) |
||||
|
if err != nil { |
||||
|
if os.IsNotExist(err) { |
||||
|
return nil, nil |
||||
|
} |
||||
|
return nil, fmt.Errorf("read record dir %s: %w", recordDir, err) |
||||
|
} |
||||
|
|
||||
|
videos := make([]VideoFile, 0, len(entries)) |
||||
|
for _, entry := range entries { |
||||
|
if entry.IsDir() { |
||||
|
continue |
||||
|
} |
||||
|
info, err := entry.Info() |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("read video info %s: %w", entry.Name(), err) |
||||
|
} |
||||
|
if !info.Mode().Type().IsRegular() { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
video, err := parseVideoFilename(entry.Name()) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
video.RelPath = mustRel(root, filepath.Join(recordDir, entry.Name())) |
||||
|
videos = append(videos, video) |
||||
|
} |
||||
|
|
||||
|
sort.Slice(videos, func(i, j int) bool { |
||||
|
if videos[i].StartTime.Equal(videos[j].StartTime) { |
||||
|
return videos[i].Filename < videos[j].Filename |
||||
|
} |
||||
|
return videos[i].StartTime.Before(videos[j].StartTime) |
||||
|
}) |
||||
|
|
||||
|
return videos, nil |
||||
|
} |
||||
|
|
||||
|
func parseImageFilename(name string) (ImageFile, error) { |
||||
|
matches := imageFilenamePattern.FindStringSubmatch(name) |
||||
|
if matches == nil { |
||||
|
return ImageFile{}, fmt.Errorf("invalid image filename %q", name) |
||||
|
} |
||||
|
|
||||
|
timestamp, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local) |
||||
|
if err != nil { |
||||
|
return ImageFile{}, fmt.Errorf("parse image timestamp %q: %w", name, err) |
||||
|
} |
||||
|
|
||||
|
return ImageFile{ |
||||
|
Filename: name, |
||||
|
Timestamp: timestamp, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func parseVideoFilename(name string) (VideoFile, error) { |
||||
|
matches := videoFilenamePattern.FindStringSubmatch(name) |
||||
|
if matches == nil { |
||||
|
return VideoFile{}, fmt.Errorf("invalid video filename %q", name) |
||||
|
} |
||||
|
|
||||
|
start, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local) |
||||
|
if err != nil { |
||||
|
return VideoFile{}, fmt.Errorf("parse video start timestamp %q: %w", name, err) |
||||
|
} |
||||
|
|
||||
|
end, err := time.ParseInLocation("060102150405", matches[1]+matches[3], time.Local) |
||||
|
if err != nil { |
||||
|
return VideoFile{}, fmt.Errorf("parse video end timestamp %q: %w", name, err) |
||||
|
} |
||||
|
if end.Before(start) { |
||||
|
end = end.Add(24 * time.Hour) |
||||
|
} |
||||
|
|
||||
|
return VideoFile{ |
||||
|
Filename: name, |
||||
|
StartTime: start, |
||||
|
EndTime: end, |
||||
|
Duration: end.Sub(start), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func mustRel(root, path string) string { |
||||
|
relPath, err := filepath.Rel(root, path) |
||||
|
if err != nil { |
||||
|
return path |
||||
|
} |
||||
|
return relPath |
||||
|
} |
||||
@ -0,0 +1,89 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"path/filepath" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func TestScanIndexesFixtureFootage(t *testing.T) { |
||||
|
root := filepath.Join("..", "..", "testdata", "footage") |
||||
|
|
||||
|
days, err := Scan(root) |
||||
|
if err != nil { |
||||
|
t.Fatalf("scan fixture footage: %v", err) |
||||
|
} |
||||
|
|
||||
|
if len(days) != 2 { |
||||
|
t.Fatalf("expected 2 days, got %d", len(days)) |
||||
|
} |
||||
|
if days[0].Date != "20260102" || days[1].Date != "20260101" { |
||||
|
t.Fatalf("expected days sorted newest first, got %#v", []string{days[0].Date, days[1].Date}) |
||||
|
} |
||||
|
|
||||
|
day := days[1] |
||||
|
if len(day.Images) != 2 { |
||||
|
t.Fatalf("expected 2 images for %s, got %d", day.Date, len(day.Images)) |
||||
|
} |
||||
|
if got := day.Images[0].RelPath; got != filepath.Join("20260101", "images", "A26010112000001.jpg") { |
||||
|
t.Fatalf("unexpected first image path %q", got) |
||||
|
} |
||||
|
if got := day.Images[1].RelPath; got != filepath.Join("20260101", "images", "A26010112050001.jpg") { |
||||
|
t.Fatalf("unexpected second image path %q", got) |
||||
|
} |
||||
|
if !day.Images[0].Timestamp.Before(day.Images[1].Timestamp) { |
||||
|
t.Fatalf("expected images sorted by timestamp ascending") |
||||
|
} |
||||
|
|
||||
|
if len(day.Videos) != 2 { |
||||
|
t.Fatalf("expected 2 videos for %s, got %d", day.Date, len(day.Videos)) |
||||
|
} |
||||
|
if got := day.Videos[0].RelPath; got != filepath.Join("20260101", "record", "A260101_120000_120015.265") { |
||||
|
t.Fatalf("unexpected first video path %q", got) |
||||
|
} |
||||
|
if got := day.Videos[0].Duration; got != 15*time.Second { |
||||
|
t.Fatalf("expected first video duration 15s, got %s", got) |
||||
|
} |
||||
|
if !day.Videos[0].StartTime.Before(day.Videos[1].StartTime) { |
||||
|
t.Fatalf("expected videos sorted by start time ascending") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseImageFilename(t *testing.T) { |
||||
|
image, err := parseImageFilename("A26010112000001.jpg") |
||||
|
if err != nil { |
||||
|
t.Fatalf("parse image filename: %v", err) |
||||
|
} |
||||
|
|
||||
|
if image.Filename != "A26010112000001.jpg" { |
||||
|
t.Fatalf("unexpected filename %q", image.Filename) |
||||
|
} |
||||
|
|
||||
|
want := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local) |
||||
|
if !image.Timestamp.Equal(want) { |
||||
|
t.Fatalf("expected timestamp %s, got %s", want, image.Timestamp) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseVideoFilename(t *testing.T) { |
||||
|
video, err := parseVideoFilename("A260101_120000_120015.265") |
||||
|
if err != nil { |
||||
|
t.Fatalf("parse video filename: %v", err) |
||||
|
} |
||||
|
|
||||
|
if video.Filename != "A260101_120000_120015.265" { |
||||
|
t.Fatalf("unexpected filename %q", video.Filename) |
||||
|
} |
||||
|
if video.Duration != 15*time.Second { |
||||
|
t.Fatalf("expected duration 15s, got %s", video.Duration) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseFilenameRejectsUnknownPattern(t *testing.T) { |
||||
|
if _, err := parseImageFilename("camera.jpg"); err == nil { |
||||
|
t.Fatalf("expected invalid image filename to fail") |
||||
|
} |
||||
|
if _, err := parseVideoFilename("camera.265"); err == nil { |
||||
|
t.Fatalf("expected invalid video filename to fail") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,257 @@ |
|||||
|
package image |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
"net/url" |
||||
|
"path/filepath" |
||||
|
"sort" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/domagojzecevic/cammonitor/internal/auth" |
||||
|
"github.com/domagojzecevic/cammonitor/internal/config" |
||||
|
"github.com/domagojzecevic/cammonitor/internal/footage" |
||||
|
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" |
||||
|
"github.com/go-chi/chi/v5" |
||||
|
) |
||||
|
|
||||
|
type Handler struct { |
||||
|
cfg *config.Config |
||||
|
idx *footage.Index |
||||
|
thumbs *Cache |
||||
|
} |
||||
|
|
||||
|
type ShellData struct { |
||||
|
Title string |
||||
|
User *auth.User |
||||
|
Months []MonthGroup |
||||
|
CurrentDate string |
||||
|
ActiveTab string |
||||
|
Content any |
||||
|
} |
||||
|
|
||||
|
type MonthGroup struct { |
||||
|
Label string |
||||
|
Days []string |
||||
|
} |
||||
|
|
||||
|
type PageData struct { |
||||
|
Date string |
||||
|
Images []ImageItem |
||||
|
ActiveIndex int |
||||
|
PrevIndex int |
||||
|
NextIndex int |
||||
|
Current ImageItem |
||||
|
} |
||||
|
|
||||
|
type ImageItem struct { |
||||
|
Index int |
||||
|
Filename string |
||||
|
RelPath string |
||||
|
RawURL string |
||||
|
ThumbURL string |
||||
|
Active bool |
||||
|
} |
||||
|
|
||||
|
func NewHandler(cfg *config.Config, idx *footage.Index) *Handler { |
||||
|
return &Handler{ |
||||
|
cfg: cfg, |
||||
|
idx: idx, |
||||
|
thumbs: NewCache(defaultCacheEntries), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) ServeRaw(w http.ResponseWriter, r *http.Request) { |
||||
|
absPath, ok := h.resolveRequestPath(w, r) |
||||
|
if !ok { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.ServeFile(w, r, absPath) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) ServeThumb(w http.ResponseWriter, r *http.Request) { |
||||
|
absPath, ok := h.resolveRequestPath(w, r) |
||||
|
if !ok { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
thumb, err := h.thumbs.Thumbnail(absPath) |
||||
|
if err != nil { |
||||
|
http.NotFound(w, r) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
w.Header().Set("Cache-Control", "max-age=3600") |
||||
|
w.Header().Set("Content-Type", "image/jpeg") |
||||
|
w.WriteHeader(http.StatusOK) |
||||
|
_, _ = w.Write(thumb) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) ServePage(w http.ResponseWriter, r *http.Request) { |
||||
|
date := chi.URLParam(r, "date") |
||||
|
day, ok := h.day(date) |
||||
|
if !ok { |
||||
|
http.NotFound(w, r) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
data := h.pageData(date, day.Images, parseIndex(r.URL.Query().Get("idx"), len(day.Images))) |
||||
|
h.render(w, r, ShellData{ |
||||
|
Title: "CamMonitor " + date + " images", |
||||
|
CurrentDate: date, |
||||
|
ActiveTab: "images", |
||||
|
Content: data, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) pageData(date string, files []footage.ImageFile, active int) PageData { |
||||
|
items := make([]ImageItem, 0, len(files)) |
||||
|
for i, file := range files { |
||||
|
items = append(items, ImageItem{ |
||||
|
Index: i, |
||||
|
Filename: file.Filename, |
||||
|
RelPath: filepath.ToSlash(file.RelPath), |
||||
|
RawURL: "/raw/image/" + pathEscape(file.RelPath), |
||||
|
ThumbURL: "/thumb/image/" + pathEscape(file.RelPath), |
||||
|
Active: i == active, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
page := PageData{ |
||||
|
Date: date, |
||||
|
Images: items, |
||||
|
ActiveIndex: active, |
||||
|
} |
||||
|
if len(items) > 0 { |
||||
|
page.PrevIndex = (active - 1 + len(items)) % len(items) |
||||
|
page.NextIndex = (active + 1) % len(items) |
||||
|
page.Current = items[active] |
||||
|
} |
||||
|
return page |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) resolveRequestPath(w http.ResponseWriter, r *http.Request) (string, bool) { |
||||
|
absPath, err := h.resolveRelPath(chi.URLParam(r, "*")) |
||||
|
if err != nil { |
||||
|
http.Error(w, "invalid image path", http.StatusBadRequest) |
||||
|
return "", false |
||||
|
} |
||||
|
return absPath, true |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) resolveRelPath(relPath string) (string, error) { |
||||
|
if h.cfg == nil || h.cfg.FootageRoot == "" { |
||||
|
return "", fmt.Errorf("missing footage root") |
||||
|
} |
||||
|
if relPath == "" { |
||||
|
return "", fmt.Errorf("missing path") |
||||
|
} |
||||
|
|
||||
|
clean := filepath.Clean(filepath.FromSlash(relPath)) |
||||
|
if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { |
||||
|
return "", fmt.Errorf("unsafe path") |
||||
|
} |
||||
|
|
||||
|
root, err := filepath.Abs(h.cfg.FootageRoot) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
target, err := filepath.Abs(filepath.Join(root, clean)) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
rel, err := filepath.Rel(root, target) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
||||
|
return "", fmt.Errorf("path escapes footage root") |
||||
|
} |
||||
|
return target, nil |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) { |
||||
|
data.User, _ = auth.UserFromContext(r.Context()) |
||||
|
data.Months = groupDaysByMonth(h.dayList()) |
||||
|
|
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
if err := imageTemplate.ExecuteTemplate(w, "base", data); err != nil { |
||||
|
http.Error(w, "render image page failed", http.StatusInternalServerError) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) dayList() []string { |
||||
|
if h.idx == nil { |
||||
|
return nil |
||||
|
} |
||||
|
return h.idx.DayList() |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) day(date string) (*footage.DayEntry, bool) { |
||||
|
if h.idx == nil { |
||||
|
return nil, false |
||||
|
} |
||||
|
return h.idx.Day(date) |
||||
|
} |
||||
|
|
||||
|
func parseIndex(value string, length int) int { |
||||
|
if length <= 0 { |
||||
|
return 0 |
||||
|
} |
||||
|
index, err := strconv.Atoi(value) |
||||
|
if err != nil || index < 0 || index >= length { |
||||
|
return 0 |
||||
|
} |
||||
|
return index |
||||
|
} |
||||
|
|
||||
|
func pathEscape(relPath string) string { |
||||
|
parts := strings.Split(filepath.ToSlash(relPath), "/") |
||||
|
for i, part := range parts { |
||||
|
parts[i] = url.PathEscape(part) |
||||
|
} |
||||
|
return strings.Join(parts, "/") |
||||
|
} |
||||
|
|
||||
|
func groupDaysByMonth(days []string) []MonthGroup { |
||||
|
groups := make(map[string][]string) |
||||
|
labels := make([]string, 0) |
||||
|
|
||||
|
for _, day := range days { |
||||
|
if len(day) != len("20060102") { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
parsed, err := time.Parse("20060102", day) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
label := parsed.Format("2006-01") |
||||
|
if _, ok := groups[label]; !ok { |
||||
|
labels = append(labels, label) |
||||
|
} |
||||
|
groups[label] = append(groups[label], day) |
||||
|
} |
||||
|
|
||||
|
sort.Sort(sort.Reverse(sort.StringSlice(labels))) |
||||
|
monthGroups := make([]MonthGroup, 0, len(labels)) |
||||
|
for _, label := range labels { |
||||
|
monthGroups = append(monthGroups, MonthGroup{ |
||||
|
Label: label, |
||||
|
Days: groups[label], |
||||
|
}) |
||||
|
} |
||||
|
return monthGroups |
||||
|
} |
||||
|
|
||||
|
var imageTemplate = template.Must(template.ParseFS( |
||||
|
webtemplates.FS, |
||||
|
webtemplates.Base, |
||||
|
webtemplates.Images, |
||||
|
)) |
||||
@ -0,0 +1,175 @@ |
|||||
|
package image |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
stdimage "image" |
||||
|
"image/color" |
||||
|
"image/jpeg" |
||||
|
"io" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/domagojzecevic/cammonitor/internal/config" |
||||
|
"github.com/domagojzecevic/cammonitor/internal/footage" |
||||
|
"github.com/go-chi/chi/v5" |
||||
|
) |
||||
|
|
||||
|
func TestThumbnailEndpointReturnsSmallJPEG(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/thumb/image/20260101/images/A26010112000001.jpg", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) |
||||
|
} |
||||
|
if contentType := response.Header().Get("Content-Type"); contentType != "image/jpeg" { |
||||
|
t.Fatalf("expected image/jpeg content type, got %q", contentType) |
||||
|
} |
||||
|
if response.Body.Len() > 30*1024 { |
||||
|
t.Fatalf("expected thumbnail <= 30 KB, got %d bytes", response.Body.Len()) |
||||
|
} |
||||
|
|
||||
|
thumbnail, err := jpeg.Decode(bytes.NewReader(response.Body.Bytes())) |
||||
|
if err != nil { |
||||
|
t.Fatalf("decode thumbnail: %v", err) |
||||
|
} |
||||
|
bounds := thumbnail.Bounds() |
||||
|
if bounds.Dx() > 160 || bounds.Dy() > 90 { |
||||
|
t.Fatalf("expected thumbnail within 160x90, got %dx%d", bounds.Dx(), bounds.Dy()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestRawEndpointServesOriginalFile(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
originalPath := filepath.Join(root, "20260101", "images", "A26010112000001.jpg") |
||||
|
original, err := os.ReadFile(originalPath) |
||||
|
if err != nil { |
||||
|
t.Fatalf("read original: %v", err) |
||||
|
} |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/raw/image/20260101/images/A26010112000001.jpg", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) |
||||
|
} |
||||
|
served, err := io.ReadAll(response.Body) |
||||
|
if err != nil { |
||||
|
t.Fatalf("read response: %v", err) |
||||
|
} |
||||
|
if !bytes.Equal(served, original) { |
||||
|
t.Fatalf("expected raw endpoint to serve original bytes unchanged") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestRawEndpointRejectsTraversal(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/raw/image/%2e%2e/secret.jpg", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusBadRequest { |
||||
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, response.Code) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestImagePageRendersViewerNavigation(t *testing.T) { |
||||
|
root := createImageFixture(t) |
||||
|
index := footage.NewIndex(root, 0) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
router := imageRouter(root, index) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/day/20260101/images?idx=1", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
router.ServeHTTP(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) |
||||
|
} |
||||
|
|
||||
|
body := response.Body.String() |
||||
|
for _, want := range []string{ |
||||
|
`data-image-browser`, |
||||
|
`data-active-index="1"`, |
||||
|
`/thumb/image/20260101/images/A26010112000001.jpg`, |
||||
|
`/raw/image/20260101/images/A26010112050001.jpg`, |
||||
|
`data-prev-index="0"`, |
||||
|
`data-next-index="0"`, |
||||
|
`keydown`, |
||||
|
`ArrowLeft`, |
||||
|
`ArrowRight`, |
||||
|
`ring-2 ring-indigo-400`, |
||||
|
} { |
||||
|
if !bytes.Contains(response.Body.Bytes(), []byte(want)) { |
||||
|
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func imageRouter(root string, index *footage.Index) chi.Router { |
||||
|
handler := NewHandler(&config.Config{FootageRoot: root}, index) |
||||
|
router := chi.NewRouter() |
||||
|
router.Get("/raw/image/*", handler.ServeRaw) |
||||
|
router.Get("/thumb/image/*", handler.ServeThumb) |
||||
|
router.Get("/day/{date}/images", handler.ServePage) |
||||
|
return router |
||||
|
} |
||||
|
|
||||
|
func createImageFixture(t *testing.T) string { |
||||
|
t.Helper() |
||||
|
|
||||
|
root := t.TempDir() |
||||
|
imageDir := filepath.Join(root, "20260101", "images") |
||||
|
if err := os.MkdirAll(imageDir, 0o755); err != nil { |
||||
|
t.Fatalf("create image dir: %v", err) |
||||
|
} |
||||
|
|
||||
|
writeJPEG(t, filepath.Join(imageDir, "A26010112000001.jpg"), color.RGBA{R: 220, G: 40, B: 40, A: 255}) |
||||
|
writeJPEG(t, filepath.Join(imageDir, "A26010112050001.jpg"), color.RGBA{R: 40, G: 90, B: 220, A: 255}) |
||||
|
return root |
||||
|
} |
||||
|
|
||||
|
func writeJPEG(t *testing.T, path string, fill color.Color) { |
||||
|
t.Helper() |
||||
|
|
||||
|
img := stdimage.NewRGBA(stdimage.Rect(0, 0, 320, 180)) |
||||
|
for y := 0; y < img.Bounds().Dy(); y++ { |
||||
|
for x := 0; x < img.Bounds().Dx(); x++ { |
||||
|
img.Set(x, y, fill) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
file, err := os.Create(path) |
||||
|
if err != nil { |
||||
|
t.Fatalf("create jpeg %s: %v", path, err) |
||||
|
} |
||||
|
defer file.Close() |
||||
|
|
||||
|
if err := jpeg.Encode(file, img, &jpeg.Options{Quality: 90}); err != nil { |
||||
|
t.Fatalf("encode jpeg %s: %v", path, err) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
package image |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
stdimage "image" |
||||
|
"image/jpeg" |
||||
|
"os" |
||||
|
"sync" |
||||
|
|
||||
|
xdraw "golang.org/x/image/draw" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
defaultCacheEntries = 500 |
||||
|
thumbnailWidth = 160 |
||||
|
thumbnailHeight = 90 |
||||
|
) |
||||
|
|
||||
|
type Cache struct { |
||||
|
mu sync.Mutex |
||||
|
max int |
||||
|
entries map[string][]byte |
||||
|
order []string |
||||
|
} |
||||
|
|
||||
|
func NewCache(max int) *Cache { |
||||
|
if max <= 0 { |
||||
|
max = defaultCacheEntries |
||||
|
} |
||||
|
return &Cache{ |
||||
|
max: max, |
||||
|
entries: make(map[string][]byte), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (c *Cache) Thumbnail(absPath string) ([]byte, error) { |
||||
|
if cached, ok := c.get(absPath); ok { |
||||
|
return cached, nil |
||||
|
} |
||||
|
|
||||
|
file, err := os.Open(absPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer file.Close() |
||||
|
|
||||
|
src, err := jpeg.Decode(file) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
dst := resizeToFit(src, thumbnailWidth, thumbnailHeight) |
||||
|
var out bytes.Buffer |
||||
|
if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 75}); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
thumb := out.Bytes() |
||||
|
c.add(absPath, thumb) |
||||
|
return append([]byte(nil), thumb...), nil |
||||
|
} |
||||
|
|
||||
|
func (c *Cache) get(key string) ([]byte, bool) { |
||||
|
c.mu.Lock() |
||||
|
defer c.mu.Unlock() |
||||
|
|
||||
|
value, ok := c.entries[key] |
||||
|
if !ok { |
||||
|
return nil, false |
||||
|
} |
||||
|
return append([]byte(nil), value...), true |
||||
|
} |
||||
|
|
||||
|
func (c *Cache) add(key string, value []byte) { |
||||
|
c.mu.Lock() |
||||
|
defer c.mu.Unlock() |
||||
|
|
||||
|
if _, ok := c.entries[key]; !ok { |
||||
|
c.order = append(c.order, key) |
||||
|
} |
||||
|
c.entries[key] = append([]byte(nil), value...) |
||||
|
|
||||
|
for len(c.order) > c.max { |
||||
|
oldest := c.order[0] |
||||
|
c.order = c.order[1:] |
||||
|
delete(c.entries, oldest) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func resizeToFit(src stdimage.Image, maxWidth, maxHeight int) stdimage.Image { |
||||
|
bounds := src.Bounds() |
||||
|
width := bounds.Dx() |
||||
|
height := bounds.Dy() |
||||
|
if width <= 0 || height <= 0 { |
||||
|
return stdimage.NewRGBA(stdimage.Rect(0, 0, 1, 1)) |
||||
|
} |
||||
|
|
||||
|
targetWidth := maxWidth |
||||
|
targetHeight := height * targetWidth / width |
||||
|
if targetHeight > maxHeight { |
||||
|
targetHeight = maxHeight |
||||
|
targetWidth = width * targetHeight / height |
||||
|
} |
||||
|
if targetWidth < 1 { |
||||
|
targetWidth = 1 |
||||
|
} |
||||
|
if targetHeight < 1 { |
||||
|
targetHeight = 1 |
||||
|
} |
||||
|
|
||||
|
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, targetWidth, targetHeight)) |
||||
|
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil) |
||||
|
return dst |
||||
|
} |
||||
@ -0,0 +1,137 @@ |
|||||
|
package web |
||||
|
|
||||
|
import ( |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
"sort" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/domagojzecevic/cammonitor/internal/auth" |
||||
|
"github.com/domagojzecevic/cammonitor/internal/footage" |
||||
|
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" |
||||
|
"github.com/go-chi/chi/v5" |
||||
|
) |
||||
|
|
||||
|
type Handler struct { |
||||
|
index *footage.Index |
||||
|
} |
||||
|
|
||||
|
type ShellData struct { |
||||
|
Title string |
||||
|
User *auth.User |
||||
|
Months []MonthGroup |
||||
|
CurrentDate string |
||||
|
ActiveTab string |
||||
|
Content any |
||||
|
} |
||||
|
|
||||
|
type MonthGroup struct { |
||||
|
Label string |
||||
|
Days []string |
||||
|
} |
||||
|
|
||||
|
type DayPageData struct { |
||||
|
Date string |
||||
|
ImageCount int |
||||
|
VideoCount int |
||||
|
} |
||||
|
|
||||
|
func NewHandler(index *footage.Index) *Handler { |
||||
|
return &Handler{index: index} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { |
||||
|
days := h.dayList() |
||||
|
if len(days) == 0 { |
||||
|
h.render(w, r, ShellData{ |
||||
|
Title: "CamMonitor", |
||||
|
Content: DayPageData{ |
||||
|
Date: "No footage", |
||||
|
}, |
||||
|
}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.Redirect(w, r, "/day/"+days[0]+"/images", http.StatusFound) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) DayOverview(w http.ResponseWriter, r *http.Request) { |
||||
|
date := chi.URLParam(r, "date") |
||||
|
day, ok := h.day(date) |
||||
|
if !ok { |
||||
|
http.NotFound(w, r) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
h.render(w, r, ShellData{ |
||||
|
Title: "CamMonitor " + date, |
||||
|
CurrentDate: date, |
||||
|
Content: DayPageData{ |
||||
|
Date: date, |
||||
|
ImageCount: len(day.Images), |
||||
|
VideoCount: len(day.Videos), |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) { |
||||
|
data.User, _ = auth.UserFromContext(r.Context()) |
||||
|
data.Months = groupDaysByMonth(h.dayList()) |
||||
|
|
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
if err := shellTemplate.ExecuteTemplate(w, "base", data); err != nil { |
||||
|
http.Error(w, "render page failed", http.StatusInternalServerError) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) dayList() []string { |
||||
|
if h.index == nil { |
||||
|
return nil |
||||
|
} |
||||
|
return h.index.DayList() |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) day(date string) (*footage.DayEntry, bool) { |
||||
|
if h.index == nil { |
||||
|
return nil, false |
||||
|
} |
||||
|
return h.index.Day(date) |
||||
|
} |
||||
|
|
||||
|
func groupDaysByMonth(days []string) []MonthGroup { |
||||
|
groups := make(map[string][]string) |
||||
|
labels := make([]string, 0) |
||||
|
|
||||
|
for _, day := range days { |
||||
|
if len(day) != len("20060102") { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
parsed, err := time.Parse("20060102", day) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
label := parsed.Format("2006-01") |
||||
|
if _, ok := groups[label]; !ok { |
||||
|
labels = append(labels, label) |
||||
|
} |
||||
|
groups[label] = append(groups[label], day) |
||||
|
} |
||||
|
|
||||
|
sort.Sort(sort.Reverse(sort.StringSlice(labels))) |
||||
|
monthGroups := make([]MonthGroup, 0, len(labels)) |
||||
|
for _, label := range labels { |
||||
|
monthGroups = append(monthGroups, MonthGroup{ |
||||
|
Label: label, |
||||
|
Days: groups[label], |
||||
|
}) |
||||
|
} |
||||
|
return monthGroups |
||||
|
} |
||||
|
|
||||
|
var shellTemplate = template.Must(template.ParseFS( |
||||
|
webtemplates.FS, |
||||
|
webtemplates.Base, |
||||
|
webtemplates.Day, |
||||
|
)) |
||||
@ -0,0 +1,39 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
|
<title>Users - CamMonitor</title> |
||||
|
<script src="https://cdn.tailwindcss.com"></script> |
||||
|
</head> |
||||
|
<body class="min-h-screen bg-slate-950 p-6 text-slate-100"> |
||||
|
<main class="mx-auto max-w-3xl space-y-6"> |
||||
|
<header class="flex items-center justify-between"> |
||||
|
<h1 class="text-2xl font-semibold">Users</h1> |
||||
|
<form method="post" action="/logout"><button class="rounded bg-slate-800 px-3 py-2">Sign out</button></form> |
||||
|
</header> |
||||
|
<form method="post" action="/admin/users" class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"> |
||||
|
<input name="username" placeholder="Username" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700"> |
||||
|
<input name="password" type="password" placeholder="Password" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700"> |
||||
|
<label class="flex items-center gap-2 text-sm"><input name="is_admin" type="checkbox"> Admin</label> |
||||
|
<button class="rounded bg-indigo-500 px-3 py-2 font-medium text-white">Add</button> |
||||
|
</form> |
||||
|
<table class="w-full border-collapse text-left"> |
||||
|
<thead><tr class="border-b border-slate-800"><th class="py-2">Username</th><th class="py-2">Role</th><th class="py-2"></th></tr></thead> |
||||
|
<tbody> |
||||
|
{{range .Users}} |
||||
|
<tr class="border-b border-slate-900"> |
||||
|
<td class="py-2">{{.Username}}</td> |
||||
|
<td class="py-2">{{if .IsAdmin}}admin{{else}}user{{end}}</td> |
||||
|
<td class="py-2 text-right"> |
||||
|
<form method="post" action="/admin/users/{{.ID}}/delete"> |
||||
|
<button class="rounded bg-slate-800 px-3 py-1">Delete</button> |
||||
|
</form> |
||||
|
</td> |
||||
|
</tr> |
||||
|
{{end}} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</main> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,72 @@ |
|||||
|
{{define "base"}} |
||||
|
<!doctype html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
|
<title>{{.Title}}</title> |
||||
|
<script src="https://cdn.tailwindcss.com"></script> |
||||
|
</head> |
||||
|
<body class="min-h-screen bg-slate-900 text-slate-100"> |
||||
|
<div class="flex min-h-screen"> |
||||
|
<aside class="hidden w-56 shrink-0 border-r border-slate-800 bg-slate-950 md:block"> |
||||
|
<div class="px-4 py-4"> |
||||
|
<a href="/" class="text-base font-semibold text-slate-100">CamMonitor</a> |
||||
|
</div> |
||||
|
<nav class="px-3 pb-6"> |
||||
|
{{range .Months}} |
||||
|
<div class="mt-5 first:mt-0"> |
||||
|
<div class="px-2 text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div> |
||||
|
<div class="mt-2 space-y-1"> |
||||
|
{{range .Days}} |
||||
|
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</nav> |
||||
|
</aside> |
||||
|
|
||||
|
<main class="flex min-h-screen flex-1 flex-col pb-16 md:pb-0"> |
||||
|
<header class="sticky top-0 z-10 border-b border-slate-800 bg-slate-900/95"> |
||||
|
<div class="flex h-14 items-center justify-between px-4 md:px-6"> |
||||
|
<div class="flex items-center gap-3"> |
||||
|
<details data-mobile-drawer class="relative md:hidden"> |
||||
|
<summary class="flex h-9 w-9 cursor-pointer list-none items-center justify-center rounded-md border border-slate-700 text-slate-100">☰</summary> |
||||
|
<div class="absolute left-0 top-11 z-30 w-72 border border-slate-800 bg-slate-950 p-4 shadow-xl"> |
||||
|
{{range .Months}} |
||||
|
<div class="mt-5 first:mt-0"> |
||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div> |
||||
|
<div class="mt-2 space-y-1"> |
||||
|
{{range .Days}} |
||||
|
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</details> |
||||
|
<a href="/" class="text-base font-semibold text-slate-100 md:hidden">CamMonitor</a> |
||||
|
</div> |
||||
|
<div class="flex items-center gap-3 text-sm text-slate-300"> |
||||
|
{{with .User}}<span>{{.Username}}</span>{{end}} |
||||
|
<form method="post" action="/logout"> |
||||
|
<button type="submit" class="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-100 hover:bg-slate-800">Logout</button> |
||||
|
</form> |
||||
|
</div> |
||||
|
</div> |
||||
|
</header> |
||||
|
|
||||
|
<section class="flex-1 px-4 py-6 md:px-8"> |
||||
|
{{template "content" .}} |
||||
|
</section> |
||||
|
</main> |
||||
|
</div> |
||||
|
|
||||
|
<nav class="fixed inset-x-0 bottom-0 z-20 grid grid-cols-2 border-t border-slate-800 bg-slate-950/95 md:hidden"> |
||||
|
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/images{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Images</a> |
||||
|
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/videos{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Videos</a> |
||||
|
</nav> |
||||
|
</body> |
||||
|
</html> |
||||
|
{{end}} |
||||
@ -0,0 +1,16 @@ |
|||||
|
{{define "content"}} |
||||
|
{{with .Content}} |
||||
|
<div class="mx-auto max-w-5xl"> |
||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> |
||||
|
<div> |
||||
|
<h1 class="text-2xl font-semibold text-slate-50">{{.Date}}</h1> |
||||
|
<p class="mt-1 text-sm text-slate-400">Footage overview</p> |
||||
|
</div> |
||||
|
<div class="flex rounded-md border border-slate-800 bg-slate-950 p-1"> |
||||
|
<a href="/day/{{.Date}}/images" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Images ({{.ImageCount}})</a> |
||||
|
<a href="/day/{{.Date}}/videos" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Videos ({{.VideoCount}})</a> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
{{end}} |
||||
@ -0,0 +1,58 @@ |
|||||
|
{{define "content"}} |
||||
|
{{with .Content}} |
||||
|
<div data-image-browser data-active-index="{{.ActiveIndex}}" class="flex h-full min-h-[calc(100vh-7rem)] flex-col gap-4"> |
||||
|
<div class="flex items-center justify-between gap-3"> |
||||
|
<div> |
||||
|
<h1 class="text-lg font-semibold text-slate-100">{{.Date}}</h1> |
||||
|
<p class="text-sm text-slate-400">Images</p> |
||||
|
</div> |
||||
|
{{if .Images}} |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.PrevIndex}}" data-prev-index="{{.PrevIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Prev</a> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.NextIndex}}" data-next-index="{{.NextIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Next</a> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
|
||||
|
{{if .Images}} |
||||
|
<div class="h-28 shrink-0 overflow-x-auto border-y border-slate-800 py-3"> |
||||
|
<div class="flex h-full gap-3"> |
||||
|
{{range .Images}} |
||||
|
<a href="/day/{{$.Content.Date}}/images?idx={{.Index}}" class="h-full w-40 shrink-0 overflow-hidden rounded-md border border-slate-800 bg-slate-950 {{if .Active}}ring-2 ring-indigo-400{{end}}"> |
||||
|
<img src="{{.ThumbURL}}" alt="{{.Filename}}" class="h-full w-full object-cover"> |
||||
|
</a> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="relative min-h-0 flex-1 overflow-hidden bg-black"> |
||||
|
<img src="{{.Current.RawURL}}" alt="{{.Current.Filename}}" class="h-full max-h-[calc(100vh-15rem)] min-h-[18rem] w-full object-contain"> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.PrevIndex}}" class="absolute left-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Prev</a> |
||||
|
<a href="/day/{{.Date}}/images?idx={{.NextIndex}}" class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Next</a> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
(() => { |
||||
|
const root = document.querySelector("[data-image-browser]"); |
||||
|
if (!root) return; |
||||
|
const active = Number(root.dataset.activeIndex || "0"); |
||||
|
const prev = root.querySelector("[data-prev-index]")?.dataset.prevIndex || "0"; |
||||
|
const next = root.querySelector("[data-next-index]")?.dataset.nextIndex || "0"; |
||||
|
const go = (index) => { |
||||
|
const url = new URL(window.location.href); |
||||
|
url.searchParams.set("idx", index); |
||||
|
window.location.href = url.toString(); |
||||
|
}; |
||||
|
window.history.replaceState({ idx: active }, "", window.location.href); |
||||
|
window.addEventListener("keydown", (event) => { |
||||
|
if (event.key === "ArrowLeft") go(prev); |
||||
|
if (event.key === "ArrowRight") go(next); |
||||
|
}); |
||||
|
})(); |
||||
|
</script> |
||||
|
{{else}} |
||||
|
<div class="border border-slate-800 bg-slate-950 p-6 text-sm text-slate-400">No images for this day.</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
{{end}} |
||||
@ -0,0 +1,25 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
|
<title>Login - CamMonitor</title> |
||||
|
<script src="https://cdn.tailwindcss.com"></script> |
||||
|
</head> |
||||
|
<body class="min-h-screen bg-slate-950 text-slate-100"> |
||||
|
<main class="mx-auto flex min-h-screen max-w-sm items-center px-6"> |
||||
|
<form method="post" action="/login" class="w-full space-y-4"> |
||||
|
<h1 class="text-2xl font-semibold">CamMonitor</h1> |
||||
|
<label class="block space-y-1"> |
||||
|
<span class="text-sm text-slate-300">Username</span> |
||||
|
<input name="username" autocomplete="username" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700"> |
||||
|
</label> |
||||
|
<label class="block space-y-1"> |
||||
|
<span class="text-sm text-slate-300">Password</span> |
||||
|
<input name="password" type="password" autocomplete="current-password" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700"> |
||||
|
</label> |
||||
|
<button type="submit" class="w-full rounded bg-indigo-500 px-3 py-2 font-medium text-white">Sign in</button> |
||||
|
</form> |
||||
|
</main> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,16 @@ |
|||||
|
package templates |
||||
|
|
||||
|
import "embed" |
||||
|
|
||||
|
const ( |
||||
|
Login = "login.html" |
||||
|
AdminUsers = "admin_users.html" |
||||
|
Base = "base.html" |
||||
|
Day = "day.html" |
||||
|
Images = "images.html" |
||||
|
) |
||||
|
|
||||
|
// FS exposes file-backed HTML templates for handlers that render web pages.
|
||||
|
//
|
||||
|
//go:embed *.html
|
||||
|
var FS embed.FS |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
Loading…
Reference in new issue