Browse Source

feat(admin): add user management

main
Domagoj Zecevic 1 month ago
parent
commit
76f9522c9b
  1. 28
      .ai/HANDOFF.md
  2. 21
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 15
      README.md
  5. 164
      internal/auth/store.go
  6. 90
      internal/auth/users_test.go
  7. 149
      internal/web/admin_test.go
  8. 186
      internal/web/app.go

28
.ai/HANDOFF.md

@ -162,3 +162,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t
| Next Role | none | | Next Role | none |
--- ---
### T-005 — dev — 2026-09-24T10:11:01Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added administrator user management with forced temporary-password changes, session invalidation, and strict 403 role protection. |
| Files Changed | README.md, internal/auth/store.go, internal/auth/users_test.go, internal/web/app.go, internal/web/admin_test.go, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; E2E not configured. |
| Commit | `feat(admin): add user management` |
| Verdict | PASS |
| Blocking Findings | none |
| Next Role | human approval |
---
### T-005 — commit_task — 2026-09-24T10:12:06Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Approved the reviewed T-005 administrator user-management work and prepared its single task commit. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | Review validation recorded in `.ai/REVIEW.md`. |
| Commit | `feat(admin): add user management` |
| Next Role | none |
---

21
.ai/REVIEW.md

@ -87,3 +87,24 @@ root-confined file opener, serve only regular files, and delegate byte-range
handling to Go's `http.ServeContent`. Tests verify the attachment's unmodified handling to Go's `http.ServeContent`. Tests verify the attachment's unmodified
bytes, content headers, `206 Partial Content`, `Content-Range`, and bytes, content headers, `206 Partial Content`, `Content-Range`, and
`Accept-Ranges` behavior. No media processing code was added. `Accept-Ranges` behavior. No media processing code was added.
## T-005 — 2026-09-24T10:11:01Z
Verdict: PASS
Findings: none.
Validation:
- `go fmt ./...` (in `golang:1.24`): PASS
- `go vet ./...` (in `golang:1.24`): PASS
- `go test ./...` (in `golang:1.24`): PASS
- E2E: NOT_CONFIGURED; project documentation declares no E2E command.
The review confirms that user-management routes are checked only after the
existing authentication and mandatory-password-change gates. Every `/admin`
route, including the namespace root, returns `403 Forbidden` with no redirect
for an authenticated standard user. The new storage operations use bcrypt,
force a password change after creates and resets, and invalidate sessions on
resets and disables. README documents the administrator workflow and the
standard-user boundary.

2
.ai/TASKS.md

@ -24,5 +24,5 @@ Command expectations:
| T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | done | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | PASS: auth storage/bcrypt/persistence/sliding-session and HTTP login/logout/forced-change/protected-route tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; Docker image build and named-volume startup smoke test | none | | T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | done | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | PASS: auth storage/bcrypt/persistence/sliding-session and HTTP login/logout/forced-change/protected-route tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; Docker image build and named-volume startup smoke test | none |
| T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | done | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | PASS: confined directory listing, traversal/symlink-escape rejection, authenticated root/folder/breadcrumb rendering, theme-local-storage markup; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none | | T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | done | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | PASS: confined directory listing, traversal/symlink-escape rejection, authenticated root/folder/breadcrumb rendering, theme-local-storage markup; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none |
| T-004 | Download & video streaming: raw-byte download endpoint with correct `Content-Type`/`Content-Disposition`, full HTTP `Range` support for video files (seek/partial content/resume) with zero transcoding anywhere in the request path | done | Downloading any file returns identical bytes to the source file (checksum match); a `Range` request against a video file returns `206 Partial Content` with the correct byte range and headers; no encoding/thumbnailing/transcoding code path exists; `README.md` documents the reverse-proxy Range-header-passthrough requirement | PASS: root-confined regular-file opens; authenticated byte-identical attachment download; video `206` `Range` response; unauthenticated delivery gate; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none | | T-004 | Download & video streaming: raw-byte download endpoint with correct `Content-Type`/`Content-Disposition`, full HTTP `Range` support for video files (seek/partial content/resume) with zero transcoding anywhere in the request path | done | Downloading any file returns identical bytes to the source file (checksum match); a `Range` request against a video file returns `206 Partial Content` with the correct byte range and headers; no encoding/thumbnailing/transcoding code path exists; `README.md` documents the reverse-proxy Range-header-passthrough requirement | PASS: root-confined regular-file opens; authenticated byte-identical attachment download; video `206` `Range` response; unauthenticated delivery gate; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none |
| T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | ready_for_implement | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | n/a | implement | | T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | done | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | PASS: admin lifecycle/session-invalidation and HTTP role-protection/temporary-password tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; E2E not configured | none |
| T-006 | Docker packaging & deployment docs: finalize multi-stage Dockerfile (small image, no build tools in final image), finalize docker-compose example with volume mounts + root config, complete `README.md` (build/run, root config format, first-login/admin credential flow, forced password change, reverse-proxy TLS + Range passthrough requirement) | ready_for_implement | `docker build` produces a working image; `docker run` with a sample volume mount and root config serves the login page only until authenticated, per Priority 1; `README.md` is complete enough for a new operator to deploy without reading source code | n/a | implement | | T-006 | Docker packaging & deployment docs: finalize multi-stage Dockerfile (small image, no build tools in final image), finalize docker-compose example with volume mounts + root config, complete `README.md` (build/run, root config format, first-login/admin credential flow, forced password change, reverse-proxy TLS + Range passthrough requirement) | ready_for_implement | `docker build` produces a working image; `docker run` with a sample volume mount and root config serves the login page only until authenticated, per Priority 1; `README.md` is complete enough for a new operator to deploy without reading source code | n/a | implement |

15
README.md

@ -79,6 +79,21 @@ The session cookie is always `HttpOnly`, `Secure`, and `SameSite=Lax`.
Terminate TLS at a reverse proxy before exposing the service; browsers will not Terminate TLS at a reverse proxy before exposing the service; browsers will not
send the secure cookie over plain HTTP. send the secure cookie over plain HTTP.
## User administration
After changing the seeded administrator password, use **Manage users** on the
roots page to open `/admin/users`. Administrators can review every account,
create either a user or administrator account, issue a replacement temporary
password, disable an account, or delete it permanently. A newly created
account and every password reset require the account holder to choose a new
password before it can access the application.
Disabling an account immediately prevents further sign-ins and invalidates its
active sessions; deleting an account also removes its sessions. Standard users
can browse, download, and stream configured roots, but every administration
route responds with `403 Forbidden` for them without redirecting to an admin
page.
## Browsing folders ## Browsing folders
After changing the temporary password, select a configured root from the home After changing the temporary password, select a configured root from the home

164
internal/auth/store.go

@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -30,24 +31,31 @@ var (
ErrPasswordUnchanged = errors.New("new password must differ from the current password") ErrPasswordUnchanged = errors.New("new password must differ from the current password")
// ErrSessionNotFound indicates an expired, deleted, or unknown session. // ErrSessionNotFound indicates an expired, deleted, or unknown session.
ErrSessionNotFound = errors.New("session not found") ErrSessionNotFound = errors.New("session not found")
// ErrUserNotFound indicates that an administrator targeted an unknown user.
ErrUserNotFound = errors.New("user not found")
// ErrInvalidUser indicates incomplete or unsupported account details.
ErrInvalidUser = errors.New("invalid user details")
// ErrEmailInUse indicates that another account already uses the email address.
ErrEmailInUse = errors.New("email address is already in use")
) )
// Role controls a user's access level. // Role controls a user's access level.
type Role string type Role string
const ( const (
// RoleAdmin can manage users in the later administration phase. // RoleAdmin can manage user accounts.
RoleAdmin Role = "admin" RoleAdmin Role = "admin"
// RoleUser can browse configured roots after authentication. // RoleUser can browse configured roots after authentication.
RoleUser Role = "user" RoleUser Role = "user"
) )
// User is the safe, password-hash-free representation of an account. // User is the safe, password-hash-free representation of an account and its status.
type User struct { type User struct {
ID int64 ID int64
Email string Email string
Role Role Role Role
MustChangePassword bool MustChangePassword bool
Disabled bool
} }
// Options customizes the persistent authentication store. // Options customizes the persistent authentication store.
@ -128,9 +136,146 @@ func (store *Store) Authenticate(email, password string) (User, error) {
return User{}, ErrInvalidCredentials return User{}, ErrInvalidCredentials
} }
user.MustChangePassword = mustChangePassword != 0 user.MustChangePassword = mustChangePassword != 0
user.Disabled = disabled != 0
return user, nil return user, nil
} }
// ListUsers returns all accounts, including disabled accounts, for an
// administrator to manage.
func (store *Store) ListUsers() ([]User, error) {
rows, err := store.db.Query(`
SELECT id, email, role, must_change_password, disabled
FROM users
ORDER BY email COLLATE NOCASE
`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer func() { _ = rows.Close() }()
users := make([]User, 0)
for rows.Next() {
var user User
var mustChangePassword, disabled int
if err := rows.Scan(&user.ID, &user.Email, &user.Role, &mustChangePassword, &disabled); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
user.MustChangePassword = mustChangePassword != 0
user.Disabled = disabled != 0
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate users: %w", err)
}
return users, nil
}
// CreateUser creates an enabled account whose temporary password must be
// changed before it can access the application.
func (store *Store) CreateUser(email, temporaryPassword string, role Role) (User, error) {
email = strings.TrimSpace(email)
if email == "" || temporaryPassword == "" || !validRole(role) {
return User{}, ErrInvalidUser
}
passwordHash, err := HashPassword(temporaryPassword)
if err != nil {
return User{}, fmt.Errorf("hash temporary password: %w", err)
}
now := store.nowUnix()
result, err := store.db.Exec(`
INSERT INTO users (email, password_hash, role, must_change_password, disabled, created_at, updated_at)
VALUES (?, ?, ?, 1, 0, ?, ?)
`, email, passwordHash, role, now, now)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed: users.email") {
return User{}, ErrEmailInUse
}
return User{}, fmt.Errorf("create user: %w", err)
}
userID, err := result.LastInsertId()
if err != nil {
return User{}, fmt.Errorf("read created user ID: %w", err)
}
return User{ID: userID, Email: email, Role: role, MustChangePassword: true}, nil
}
// ResetPassword replaces a user's password with a new temporary password and
// invalidates every existing session for that user.
func (store *Store) ResetPassword(userID int64, temporaryPassword string) error {
if userID <= 0 || temporaryPassword == "" {
return ErrInvalidUser
}
passwordHash, err := HashPassword(temporaryPassword)
if err != nil {
return fmt.Errorf("hash temporary password: %w", err)
}
transaction, err := store.db.Begin()
if err != nil {
return fmt.Errorf("begin password reset: %w", err)
}
defer func() { _ = transaction.Rollback() }()
result, err := transaction.Exec(`
UPDATE users
SET password_hash = ?, must_change_password = 1, updated_at = ?
WHERE id = ?
`, passwordHash, store.nowUnix(), userID)
if err != nil {
return fmt.Errorf("reset password: %w", err)
}
if err := requireAffectedUser(result); err != nil {
return err
}
if _, err := transaction.Exec("DELETE FROM sessions WHERE user_id = ?", userID); err != nil {
return fmt.Errorf("remove reset user sessions: %w", err)
}
if err := transaction.Commit(); err != nil {
return fmt.Errorf("commit password reset: %w", err)
}
return nil
}
// DisableUser prevents sign-in and invalidates every active session for the
// account while preserving it for later administrative review or deletion.
func (store *Store) DisableUser(userID int64) error {
if userID <= 0 {
return ErrInvalidUser
}
transaction, err := store.db.Begin()
if err != nil {
return fmt.Errorf("begin user disable: %w", err)
}
defer func() { _ = transaction.Rollback() }()
result, err := transaction.Exec("UPDATE users SET disabled = 1, updated_at = ? WHERE id = ?", store.nowUnix(), userID)
if err != nil {
return fmt.Errorf("disable user: %w", err)
}
if err := requireAffectedUser(result); err != nil {
return err
}
if _, err := transaction.Exec("DELETE FROM sessions WHERE user_id = ?", userID); err != nil {
return fmt.Errorf("remove disabled user sessions: %w", err)
}
if err := transaction.Commit(); err != nil {
return fmt.Errorf("commit user disable: %w", err)
}
return nil
}
// DeleteUser permanently removes an account and its server-side sessions.
func (store *Store) DeleteUser(userID int64) error {
if userID <= 0 {
return ErrInvalidUser
}
result, err := store.db.Exec("DELETE FROM users WHERE id = ?", userID)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
return requireAffectedUser(result)
}
// ChangePassword verifies the current password, rejects reuse, and clears the // ChangePassword verifies the current password, rejects reuse, and clears the
// forced-password-change flag. // forced-password-change flag.
func (store *Store) ChangePassword(userID int64, currentPassword, newPassword string) error { func (store *Store) ChangePassword(userID int64, currentPassword, newPassword string) error {
@ -331,6 +476,21 @@ func (store *Store) nowUnix() int64 {
return store.clock().UTC().Unix() return store.clock().UTC().Unix()
} }
func validRole(role Role) bool {
return role == RoleAdmin || role == RoleUser
}
func requireAffectedUser(result sql.Result) error {
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read affected users: %w", err)
}
if affected == 0 {
return ErrUserNotFound
}
return nil
}
func randomToken() (string, error) { func randomToken() (string, error) {
bytes := make([]byte, 32) bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {

90
internal/auth/users_test.go

@ -0,0 +1,90 @@
package auth
import (
"errors"
"testing"
)
func TestUserManagementLifecycle(t *testing.T) {
store := openTestStore(t, t.TempDir())
created, err := store.CreateUser("reader@example.com", "Temporary-1!", RoleUser)
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
if created.Role != RoleUser || !created.MustChangePassword || created.Disabled {
t.Errorf("CreateUser() = %#v, want enabled user role with forced password change", created)
}
user, err := store.Authenticate(created.Email, "Temporary-1!")
if err != nil {
t.Fatalf("Authenticate(created user) error = %v", err)
}
if !user.MustChangePassword {
t.Error("created user's temporary password does not require a change")
}
token, err := store.CreateSession(user.ID)
if err != nil {
t.Fatalf("CreateSession() error = %v", err)
}
if err := store.ResetPassword(created.ID, "Replacement-2!"); err != nil {
t.Fatalf("ResetPassword() error = %v", err)
}
if _, err := store.LookupSession(token); !errors.Is(err, ErrSessionNotFound) {
t.Errorf("LookupSession() after reset error = %v, want ErrSessionNotFound", err)
}
if _, err := store.Authenticate(created.Email, "Temporary-1!"); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("Authenticate(old password) error = %v, want ErrInvalidCredentials", err)
}
user, err = store.Authenticate(created.Email, "Replacement-2!")
if err != nil {
t.Fatalf("Authenticate(reset password) error = %v", err)
}
if !user.MustChangePassword {
t.Error("reset password does not require a change")
}
token, err = store.CreateSession(user.ID)
if err != nil {
t.Fatalf("CreateSession() after reset error = %v", err)
}
if err := store.DisableUser(created.ID); err != nil {
t.Fatalf("DisableUser() error = %v", err)
}
if _, err := store.Authenticate(created.Email, "Replacement-2!"); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("Authenticate(disabled user) error = %v, want ErrInvalidCredentials", err)
}
if _, err := store.LookupSession(token); !errors.Is(err, ErrSessionNotFound) {
t.Errorf("LookupSession() for disabled user error = %v, want ErrSessionNotFound", err)
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("ListUsers() error = %v", err)
}
managed := userByEmail(users, created.Email)
if managed == nil || !managed.Disabled {
t.Errorf("ListUsers() did not return %q as disabled: %#v", created.Email, users)
}
if err := store.DeleteUser(created.ID); err != nil {
t.Fatalf("DeleteUser() error = %v", err)
}
users, err = store.ListUsers()
if err != nil {
t.Fatalf("ListUsers() after deletion error = %v", err)
}
if userByEmail(users, created.Email) != nil {
t.Errorf("ListUsers() still returned deleted user %q", created.Email)
}
}
func userByEmail(users []User, email string) *User {
for index := range users {
if users[index].Email == email {
return &users[index]
}
}
return nil
}

149
internal/web/admin_test.go

@ -0,0 +1,149 @@
package web
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"webfilebrowser/internal/auth"
)
func TestNonAdminUsersAreForbiddenFromEveryAdminRoute(t *testing.T) {
store, handler := newTestApplication(t)
created, err := store.CreateUser("reader@example.com", "Temporary-1!", auth.RoleUser)
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
userCookie := completeUserPasswordChange(t, handler, created.Email, "Temporary-1!", "Reader-password-2!")
for _, target := range []struct {
method string
path string
}{
{method: http.MethodGet, path: "/admin"},
{method: http.MethodGet, path: adminUsersPath},
{method: http.MethodPost, path: adminCreateUserPath},
{method: http.MethodPost, path: adminResetUserPath},
{method: http.MethodPost, path: adminDisableUserPath},
{method: http.MethodPost, path: adminDeleteUserPath},
} {
t.Run(target.method+" "+target.path, func(t *testing.T) {
response := postOrGetWithCookie(handler, target.method, target.path, userCookie)
if response.Code != http.StatusForbidden {
t.Errorf("%s %s status = %d, want %d", target.method, target.path, response.Code, http.StatusForbidden)
}
if response.Header().Get("Location") != "" {
t.Errorf("%s %s redirected to %q, want no redirect", target.method, target.path, response.Header().Get("Location"))
}
})
}
}
func TestAdminCanManageUsersAndTemporaryPasswordsRequireChange(t *testing.T) {
store, handler := newTestApplication(t)
adminCookie := completeSeedPasswordChange(t, handler)
usersResponse := getWithCookie(handler, adminUsersPath, adminCookie)
if usersResponse.Code != http.StatusOK || !strings.Contains(usersResponse.Body.String(), "Create user") {
t.Fatalf("GET %s = (%d, %q), want admin management page", adminUsersPath, usersResponse.Code, usersResponse.Body.String())
}
createResponse := postForm(handler, adminCreateUserPath, url.Values{
"email": {"new-reader@example.com"},
"temporary_password": {"Temporary-1!"},
"role": {string(auth.RoleUser)},
}, adminCookie)
if createResponse.Code != http.StatusSeeOther || createResponse.Header().Get("Location") != adminUsersPath {
t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminCreateUserPath, createResponse.Code, createResponse.Header().Get("Location"), adminUsersPath)
}
created := managedUser(t, store, "new-reader@example.com")
loginResponse := postForm(handler, loginPath, url.Values{
"email": {created.Email},
"password": {"Temporary-1!"},
}, nil)
if loginResponse.Code != http.StatusSeeOther || loginResponse.Header().Get("Location") != changePasswordPath {
t.Fatalf("new user login = (%d, %q), want redirect to %s", loginResponse.Code, loginResponse.Header().Get("Location"), changePasswordPath)
}
resetResponse := postForm(handler, adminResetUserPath, url.Values{
"id": {strconv.FormatInt(created.ID, 10)},
"temporary_password": {"Replacement-2!"},
}, adminCookie)
if resetResponse.Code != http.StatusSeeOther || resetResponse.Header().Get("Location") != adminUsersPath {
t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminResetUserPath, resetResponse.Code, resetResponse.Header().Get("Location"), adminUsersPath)
}
if _, err := store.Authenticate(created.Email, "Replacement-2!"); err != nil {
t.Fatalf("Authenticate(reset user) error = %v", err)
}
disableResponse := postForm(handler, adminDisableUserPath, url.Values{"id": {strconv.FormatInt(created.ID, 10)}}, adminCookie)
if disableResponse.Code != http.StatusSeeOther || disableResponse.Header().Get("Location") != adminUsersPath {
t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminDisableUserPath, disableResponse.Code, disableResponse.Header().Get("Location"), adminUsersPath)
}
if _, err := store.Authenticate(created.Email, "Replacement-2!"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Errorf("Authenticate(disabled user) error = %v, want invalid credentials", err)
}
deleteResponse := postForm(handler, adminDeleteUserPath, url.Values{"id": {strconv.FormatInt(created.ID, 10)}}, adminCookie)
if deleteResponse.Code != http.StatusSeeOther || deleteResponse.Header().Get("Location") != adminUsersPath {
t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminDeleteUserPath, deleteResponse.Code, deleteResponse.Header().Get("Location"), adminUsersPath)
}
if got := managedUserOrNil(t, store, created.Email); got != nil {
t.Errorf("deleted user remains in management list: %#v", got)
}
}
func completeUserPasswordChange(t *testing.T, handler http.Handler, email, temporaryPassword, newPassword string) *http.Cookie {
t.Helper()
loginResponse := postForm(handler, loginPath, url.Values{
"email": {email},
"password": {temporaryPassword},
}, nil)
if loginResponse.Code != http.StatusSeeOther || loginResponse.Header().Get("Location") != changePasswordPath {
t.Fatalf("temporary user login = (%d, %q), want redirect to %s", loginResponse.Code, loginResponse.Header().Get("Location"), changePasswordPath)
}
cookie := responseCookie(t, loginResponse, sessionCookieName)
changeResponse := postForm(handler, changePasswordPath, url.Values{
"current_password": {temporaryPassword},
"new_password": {newPassword},
}, cookie)
if changeResponse.Code != http.StatusSeeOther {
t.Fatalf("POST %s = %d, want %d", changePasswordPath, changeResponse.Code, http.StatusSeeOther)
}
return cookie
}
func postOrGetWithCookie(handler http.Handler, method, target string, cookie *http.Cookie) *httptest.ResponseRecorder {
if method == http.MethodGet {
return getWithCookie(handler, target, cookie)
}
return postForm(handler, target, url.Values{}, cookie)
}
func managedUser(t *testing.T, store *auth.Store, email string) auth.User {
t.Helper()
user := managedUserOrNil(t, store, email)
if user == nil {
t.Fatalf("ListUsers() did not return %q", email)
}
return *user
}
func managedUserOrNil(t *testing.T, store *auth.Store, email string) *auth.User {
t.Helper()
users, err := store.ListUsers()
if err != nil {
t.Fatalf("ListUsers() error = %v", err)
}
for index := range users {
if users[index].Email == email {
return &users[index]
}
}
return nil
}

186
internal/web/app.go

@ -19,13 +19,18 @@ import (
) )
const ( const (
loginPath = "/login" loginPath = "/login"
logoutPath = "/logout" logoutPath = "/logout"
changePasswordPath = "/change-password" changePasswordPath = "/change-password"
browsePath = "/browse" browsePath = "/browse"
downloadPath = "/download" downloadPath = "/download"
streamPath = "/stream" streamPath = "/stream"
sessionCookieName = "webfilebrowser_session" adminUsersPath = "/admin/users"
adminCreateUserPath = "/admin/users/create"
adminResetUserPath = "/admin/users/reset"
adminDisableUserPath = "/admin/users/disable"
adminDeleteUserPath = "/admin/users/delete"
sessionCookieName = "webfilebrowser_session"
) )
var ( var (
@ -41,15 +46,22 @@ var (
<label>New password <input type="password" name="new_password" required></label><button type="submit">Change password</button></form></main></body></html>`)) <label>New password <input type="password" name="new_password" required></label><button type="submit">Change password</button></form></main></body></html>`))
rootsTemplate = template.Must(template.New("roots").Parse(pageStart + ` rootsTemplate = template.Must(template.New("roots").Parse(pageStart + `
<main><header><h1>webFileBrowser</h1><button id="theme-toggle" type="button">Toggle theme</button></header> <main><header><h1>webFileBrowser</h1><button id="theme-toggle" type="button">Toggle theme</button></header>
<p>Signed in as {{.UserEmail}}.</p><h2>Roots</h2><ul class="roots">{{range .Roots}}<li><a href="{{.URL}}">{{.Name}}</a></li>{{else}}<li>No roots are configured.</li>{{end}}</ul>` + signOutForm + pageEnd)) <p>Signed in as {{.UserEmail}}.</p>{{if .IsAdmin}}<p><a href="/admin/users">Manage users</a></p>{{end}}<h2>Roots</h2><ul class="roots">{{range .Roots}}<li><a href="{{.URL}}">{{.Name}}</a></li>{{else}}<li>No roots are configured.</li>{{end}}</ul>` + signOutForm + pageEnd))
directoryTemplate = template.Must(template.New("directory").Parse(pageStart + ` directoryTemplate = template.Must(template.New("directory").Parse(pageStart + `
<main><header><h1>webFileBrowser</h1><button id="theme-toggle" type="button">Toggle theme</button></header> <main><header><h1>webFileBrowser</h1><button id="theme-toggle" type="button">Toggle theme</button></header>
<p>Signed in as {{.UserEmail}}.</p><nav aria-label="Breadcrumb"><ol class="breadcrumbs">{{range .Breadcrumbs}}<li><a href="{{.URL}}">{{.Name}}</a></li>{{end}}</ol></nav> <p>Signed in as {{.UserEmail}}.</p><nav aria-label="Breadcrumb"><ol class="breadcrumbs">{{range .Breadcrumbs}}<li><a href="{{.URL}}">{{.Name}}</a></li>{{end}}</ol></nav>
<table><thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Modified</th><th>Actions</th></tr></thead><tbody>{{range .Entries}}<tr><td>{{if .URL}}<a href="{{.URL}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Type}}</td><td>{{.Size}}</td><td>{{.Modified}}</td><td>{{if .DownloadURL}}<a href="{{.DownloadURL}}">Download</a>{{end}}{{if .StreamURL}} · <a href="{{.StreamURL}}">Stream</a>{{end}}</td></tr>{{else}}<tr><td colspan="5">This folder is empty.</td></tr>{{end}}</tbody></table>` + signOutForm + pageEnd)) <table><thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Modified</th><th>Actions</th></tr></thead><tbody>{{range .Entries}}<tr><td>{{if .URL}}<a href="{{.URL}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Type}}</td><td>{{.Size}}</td><td>{{.Modified}}</td><td>{{if .DownloadURL}}<a href="{{.DownloadURL}}">Download</a>{{end}}{{if .StreamURL}} · <a href="{{.StreamURL}}">Stream</a>{{end}}</td></tr>{{else}}<tr><td colspan="5">This folder is empty.</td></tr>{{end}}</tbody></table>` + signOutForm + pageEnd))
adminUsersTemplate = template.Must(template.New("admin-users").Parse(pageStart + `
<main><header><h1>User management</h1><button id="theme-toggle" type="button">Toggle theme</button></header>
<p>Signed in as {{.UserEmail}}. <a href="/">Back to roots</a></p>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}
<section><h2>Create user</h2><form method="post" action="/admin/users/create"><label>Email <input type="email" name="email" required></label>
<label>Temporary password <input type="password" name="temporary_password" required></label>
<label>Role <select name="role"><option value="user">User</option><option value="admin">Administrator</option></select></label><button type="submit">Create user</button></form></section>
<section><h2>Users</h2><table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Password</th><th>Actions</th></tr></thead><tbody>{{range .Users}}<tr><td>{{.Email}}</td><td>{{.Role}}</td><td>{{if .Disabled}}Disabled{{else}}Enabled{{end}}</td><td>{{if .MustChangePassword}}Change required{{else}}Current{{end}}</td><td><form class="compact" method="post" action="/admin/users/reset"><input type="hidden" name="id" value="{{.ID}}"><label>Temporary password <input type="password" name="temporary_password" required></label><button type="submit">Reset password</button></form><form class="compact" method="post" action="/admin/users/disable"><input type="hidden" name="id" value="{{.ID}}"><button type="submit">Disable</button></form><form class="compact" method="post" action="/admin/users/delete"><input type="hidden" name="id" value="{{.ID}}"><button type="submit">Delete</button></form></td></tr>{{else}}<tr><td colspan="5">No users exist.</td></tr>{{end}}</tbody></table></section>` + signOutForm + pageEnd))
) )
const pageStart = `<!doctype html><html lang="en" data-theme="light"><head><meta charset="utf-8"><title>webFileBrowser</title><style> const pageStart = `<!doctype html><html lang="en" data-theme="light"><head><meta charset="utf-8"><title>webFileBrowser</title><style>
:root{color-scheme:light;--background:#f6f7fb;--surface:#fff;--text:#202534;--muted:#5d6577;--border:#d7dce8;--link:#2251b5}:root[data-theme="dark"]{color-scheme:dark;--background:#151821;--surface:#202532;--text:#f1f4fb;--muted:#b7c0d2;--border:#3c465b;--link:#9ec0ff}*{box-sizing:border-box}body{margin:0;background:var(--background);color:var(--text);font:16px system-ui,sans-serif}main{max-width:960px;margin:2rem auto;padding:2rem;background:var(--surface);border:1px solid var(--border);border-radius:12px}header{display:flex;justify-content:space-between;align-items:center;gap:1rem}h1{margin:0}a{color:var(--link)}button{font:inherit;padding:.45rem .75rem}table{width:100%;border-collapse:collapse}th,td{padding:.7rem;text-align:left;border-bottom:1px solid var(--border)}.roots,.breadcrumbs{padding-left:1.2rem}.breadcrumbs{display:flex;gap:.5rem;flex-wrap:wrap}.breadcrumbs li+li:before{content:"/";margin-right:.5rem;color:var(--muted)}form{margin-top:1.5rem}</style></head><body>` :root{color-scheme:light;--background:#f6f7fb;--surface:#fff;--text:#202534;--muted:#5d6577;--border:#d7dce8;--link:#2251b5}:root[data-theme="dark"]{color-scheme:dark;--background:#151821;--surface:#202532;--text:#f1f4fb;--muted:#b7c0d2;--border:#3c465b;--link:#9ec0ff}*{box-sizing:border-box}body{margin:0;background:var(--background);color:var(--text);font:16px system-ui,sans-serif}main{max-width:960px;margin:2rem auto;padding:2rem;background:var(--surface);border:1px solid var(--border);border-radius:12px}header{display:flex;justify-content:space-between;align-items:center;gap:1rem}h1{margin:0}a{color:var(--link)}button{font:inherit;padding:.45rem .75rem}table{width:100%;border-collapse:collapse}th,td{padding:.7rem;text-align:left;border-bottom:1px solid var(--border)}.roots,.breadcrumbs{padding-left:1.2rem}.breadcrumbs{display:flex;gap:.5rem;flex-wrap:wrap}.breadcrumbs li+li:before{content:"/";margin-right:.5rem;color:var(--muted)}form{margin-top:1.5rem}.compact{margin:.5rem 0}</style></head><body>`
const signOutForm = `<form method="post" action="/logout"><button type="submit">Sign out</button></form>` const signOutForm = `<form method="post" action="/logout"><button type="submit">Sign out</button></form>`
@ -110,6 +122,14 @@ func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Requ
http.Redirect(writer, request, "/", http.StatusSeeOther) http.Redirect(writer, request, "/", http.StatusSeeOther)
return return
} }
if request.URL.Path == "/admin" || strings.HasPrefix(request.URL.Path, "/admin/") {
if user.Role != auth.RoleAdmin {
http.Error(writer, "Forbidden.", http.StatusForbidden)
return
}
app.handleAdmin(writer, request, user)
return
}
switch request.URL.Path { switch request.URL.Path {
case "/": case "/":
app.handleRoots(writer, request, user) app.handleRoots(writer, request, user)
@ -210,7 +230,146 @@ func (app *application) handleRoots(writer http.ResponseWriter, request *http.Re
for _, root := range app.browser.Roots() { for _, root := range app.browser.Roots() {
roots = append(roots, rootLink{Name: root.Name, URL: browseURL(root.Name, "")}) roots = append(roots, rootLink{Name: root.Name, URL: browseURL(root.Name, "")})
} }
renderTemplate(writer, rootsTemplate, rootPage{UserEmail: user.Email, Roots: roots}) renderTemplate(writer, rootsTemplate, rootPage{UserEmail: user.Email, IsAdmin: user.Role == auth.RoleAdmin, Roots: roots})
}
func (app *application) handleAdmin(writer http.ResponseWriter, request *http.Request, user auth.User) {
switch request.URL.Path {
case adminUsersPath:
app.handleAdminUsers(writer, request, user)
case adminCreateUserPath:
app.handleAdminCreateUser(writer, request, user)
case adminResetUserPath:
app.handleAdminResetUser(writer, request, user)
case adminDisableUserPath:
app.handleAdminDisableUser(writer, request, user)
case adminDeleteUserPath:
app.handleAdminDeleteUser(writer, request, user)
default:
http.NotFound(writer, request)
}
}
func (app *application) handleAdminUsers(writer http.ResponseWriter, request *http.Request, user auth.User) {
if request.Method != http.MethodGet {
methodNotAllowed(writer, http.MethodGet)
return
}
app.renderAdminUsers(writer, http.StatusOK, user, "")
}
func (app *application) handleAdminCreateUser(writer http.ResponseWriter, request *http.Request, user auth.User) {
if request.Method != http.MethodPost {
methodNotAllowed(writer, http.MethodPost)
return
}
if err := request.ParseForm(); err != nil {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "Unable to read user details.")
return
}
_, err := app.store.CreateUser(request.FormValue("email"), request.FormValue("temporary_password"), auth.Role(request.FormValue("role")))
if errors.Is(err, auth.ErrInvalidUser) {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "Enter an email, temporary password, and valid role.")
return
}
if errors.Is(err, auth.ErrEmailInUse) {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "That email address is already in use.")
return
}
if err != nil {
http.Error(writer, "Unable to create user.", http.StatusInternalServerError)
return
}
http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther)
}
func (app *application) handleAdminResetUser(writer http.ResponseWriter, request *http.Request, user auth.User) {
if request.Method != http.MethodPost {
methodNotAllowed(writer, http.MethodPost)
return
}
userID, ok := parseAdminUserID(writer, request, user, app)
if !ok {
return
}
err := app.store.ResetPassword(userID, request.FormValue("temporary_password"))
if errors.Is(err, auth.ErrInvalidUser) || errors.Is(err, auth.ErrUserNotFound) {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "Enter an existing user and temporary password.")
return
}
if err != nil {
http.Error(writer, "Unable to reset password.", http.StatusInternalServerError)
return
}
http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther)
}
func (app *application) handleAdminDisableUser(writer http.ResponseWriter, request *http.Request, user auth.User) {
if request.Method != http.MethodPost {
methodNotAllowed(writer, http.MethodPost)
return
}
userID, ok := parseAdminUserID(writer, request, user, app)
if !ok {
return
}
err := app.store.DisableUser(userID)
if errors.Is(err, auth.ErrInvalidUser) || errors.Is(err, auth.ErrUserNotFound) {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "Select an existing user to disable.")
return
}
if err != nil {
http.Error(writer, "Unable to disable user.", http.StatusInternalServerError)
return
}
http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther)
}
func (app *application) handleAdminDeleteUser(writer http.ResponseWriter, request *http.Request, user auth.User) {
if request.Method != http.MethodPost {
methodNotAllowed(writer, http.MethodPost)
return
}
userID, ok := parseAdminUserID(writer, request, user, app)
if !ok {
return
}
err := app.store.DeleteUser(userID)
if errors.Is(err, auth.ErrInvalidUser) || errors.Is(err, auth.ErrUserNotFound) {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "Select an existing user to delete.")
return
}
if err != nil {
http.Error(writer, "Unable to delete user.", http.StatusInternalServerError)
return
}
http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther)
}
func parseAdminUserID(writer http.ResponseWriter, request *http.Request, user auth.User, app *application) (int64, bool) {
if err := request.ParseForm(); err != nil {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "Unable to read user details.")
return 0, false
}
userID, err := strconv.ParseInt(request.FormValue("id"), 10, 64)
if err != nil || userID <= 0 {
app.renderAdminUsers(writer, http.StatusBadRequest, user, "Select an existing user.")
return 0, false
}
return userID, true
}
func (app *application) renderAdminUsers(writer http.ResponseWriter, status int, user auth.User, message string) {
users, err := app.store.ListUsers()
if err != nil {
http.Error(writer, "Unable to load users.", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(status)
if err := adminUsersTemplate.Execute(writer, adminUsersPage{UserEmail: user.Email, Users: users, Error: message}); err != nil {
http.Error(writer, "Unable to render page.", http.StatusInternalServerError)
}
} }
func (app *application) handleBrowse(writer http.ResponseWriter, request *http.Request, user auth.User) { func (app *application) handleBrowse(writer http.ResponseWriter, request *http.Request, user auth.User) {
@ -335,9 +494,16 @@ func (app *application) clearSessionCookie(writer http.ResponseWriter) {
type rootPage struct { type rootPage struct {
UserEmail string UserEmail string
IsAdmin bool
Roots []rootLink Roots []rootLink
} }
type adminUsersPage struct {
UserEmail string
Users []auth.User
Error string
}
type rootLink struct { type rootLink struct {
Name string Name string
URL string URL string

Loading…
Cancel
Save