// Package web provides the login-gated HTTP surface for the application. package web import ( "errors" "html/template" "mime" "net/http" "net/url" "os" "path" "path/filepath" "strconv" "strings" "time" "webfilebrowser/internal/auth" "webfilebrowser/internal/browser" "webfilebrowser/internal/config" ) const ( loginPath = "/login" logoutPath = "/logout" changePasswordPath = "/change-password" browsePath = "/browse" downloadPath = "/download" streamPath = "/stream" adminUsersPath = "/admin/users" adminCreateUserPath = "/admin/users/create" adminResetUserPath = "/admin/users/reset" adminDisableUserPath = "/admin/users/disable" adminDeleteUserPath = "/admin/users/delete" sessionCookieName = "webfilebrowser_session" // insecureCookiesEnvironment is an unsafe local-testing opt-in. Only an // explicit true value removes the Secure cookie attribute. insecureCookiesEnvironment = "WEB_FILE_BROWSER_INSECURE_COOKIES" ) var ( loginTemplate = template.Must(template.New("login").Parse(` Sign in

Sign in

{{if .Error}}

{{.Error}}

{{end}}
`)) changePasswordTemplate = template.Must(template.New("change-password").Parse(` Change password

Change your password

You must choose a new password before continuing.

{{if .Error}}

{{.Error}}

{{end}}
`)) rootsTemplate = template.Must(template.New("roots").Parse(pageStart + `

webFileBrowser

Signed in as {{.UserEmail}}.

{{if .IsAdmin}}

Manage users

{{end}}

Roots

` + signOutForm + pageEnd)) directoryTemplate = template.Must(template.New("directory").Parse(pageStart + `

webFileBrowser

Signed in as {{.UserEmail}}.

{{range .Entries}}{{else}}{{end}}
NameTypeSizeModifiedActions
{{if .URL}}{{.Name}}{{else}}{{.Name}}{{end}}{{.Type}}{{.Size}}{{.Modified}}{{if .DownloadURL}}Download{{end}}{{if .StreamURL}} ยท Stream{{end}}
This folder is empty.
` + signOutForm + pageEnd)) adminUsersTemplate = template.Must(template.New("admin-users").Parse(pageStart + `

User management

Signed in as {{.UserEmail}}. Back to roots

{{if .Error}}

{{.Error}}

{{end}}

Create user

Users

{{range .Users}}{{else}}{{end}}
EmailRoleStatusPasswordActions
{{.Email}}{{.Role}}{{if .Disabled}}Disabled{{else}}Enabled{{end}}{{if .MustChangePassword}}Change required{{else}}Current{{end}}
No users exist.
` + signOutForm + pageEnd)) ) const pageStart = `webFileBrowser` const signOutForm = `
` const pageEnd = `` // New returns an HTTP handler that requires a valid server-side session for // every route other than the login endpoint. Setting // WEB_FILE_BROWSER_INSECURE_COOKIES=true is an unsafe local-testing opt-in // that lets browsers send the session cookie over plain HTTP. func New(store *auth.Store, roots ...config.Root) http.Handler { fileBrowser, err := browser.New(roots) return &application{ store: store, browser: fileBrowser, browserError: err, secureCookies: !strings.EqualFold(strings.TrimSpace(os.Getenv(insecureCookiesEnvironment)), "true"), } } type application struct { store *auth.Store browser *browser.Browser browserError error secureCookies bool } func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if request.URL.Path == loginPath { app.handleLogin(writer, request) return } user, token, ok := app.currentUser(writer, request) if !ok { return } if request.URL.Path == logoutPath { app.handleLogout(writer, request, token) return } if user.MustChangePassword { if request.URL.Path != changePasswordPath { http.Redirect(writer, request, changePasswordPath, http.StatusSeeOther) return } app.handleChangePassword(writer, request, user) return } if request.URL.Path == changePasswordPath { http.Redirect(writer, request, "/", http.StatusSeeOther) 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 { case "/": app.handleRoots(writer, request, user) case browsePath: app.handleBrowse(writer, request, user) case downloadPath: app.handleFileDelivery(writer, request, true) case streamPath: app.handleFileDelivery(writer, request, false) default: http.NotFound(writer, request) } } func (app *application) handleLogin(writer http.ResponseWriter, request *http.Request) { switch request.Method { case http.MethodGet: renderLogin(writer, http.StatusOK, "") case http.MethodPost: if err := request.ParseForm(); err != nil { renderLogin(writer, http.StatusBadRequest, "Unable to read sign-in details.") return } user, err := app.store.Authenticate(request.FormValue("email"), request.FormValue("password")) if err != nil { renderLogin(writer, http.StatusUnauthorized, "Invalid email or password.") return } token, err := app.store.CreateSession(user.ID) if err != nil { http.Error(writer, "Unable to create session.", http.StatusInternalServerError) return } app.setSessionCookie(writer, token) destination := "/" if user.MustChangePassword { destination = changePasswordPath } http.Redirect(writer, request, destination, http.StatusSeeOther) default: writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) } } func (app *application) handleChangePassword(writer http.ResponseWriter, request *http.Request, user auth.User) { switch request.Method { case http.MethodGet: renderChangePassword(writer, http.StatusOK, "") case http.MethodPost: if err := request.ParseForm(); err != nil { renderChangePassword(writer, http.StatusBadRequest, "Unable to read password details.") return } err := app.store.ChangePassword(user.ID, request.FormValue("current_password"), request.FormValue("new_password")) if errors.Is(err, auth.ErrPasswordUnchanged) { renderChangePassword(writer, http.StatusBadRequest, "Your new password must differ from the current password.") return } if errors.Is(err, auth.ErrInvalidCredentials) { renderChangePassword(writer, http.StatusBadRequest, "Your current password is incorrect.") return } if err != nil { http.Error(writer, "Unable to change password.", http.StatusInternalServerError) return } http.Redirect(writer, request, "/", http.StatusSeeOther) default: writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) } } func (app *application) handleLogout(writer http.ResponseWriter, request *http.Request, token string) { if request.Method != http.MethodPost { methodNotAllowed(writer, http.MethodPost) return } if err := app.store.DeleteSession(token); err != nil { http.Error(writer, "Unable to end session.", http.StatusInternalServerError) return } app.clearSessionCookie(writer) http.Redirect(writer, request, loginPath, http.StatusSeeOther) } func (app *application) handleRoots(writer http.ResponseWriter, request *http.Request, user auth.User) { if request.Method != http.MethodGet { methodNotAllowed(writer, http.MethodGet) return } if app.browserError != nil { http.Error(writer, "Unable to load configured roots.", http.StatusInternalServerError) return } roots := make([]rootLink, 0, len(app.browser.Roots())) for _, root := range app.browser.Roots() { roots = append(roots, rootLink{Name: root.Name, URL: browseURL(root.Name, "")}) } 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) { if request.Method != http.MethodGet { methodNotAllowed(writer, http.MethodGet) return } if app.browserError != nil { http.Error(writer, "Unable to load configured roots.", http.StatusInternalServerError) return } directory, err := app.browser.List(request.URL.Query().Get("root"), request.URL.Query().Get("path")) if errors.Is(err, browser.ErrRootNotFound) || errors.Is(err, browser.ErrPathOutsideRoot) || errors.Is(err, browser.ErrDirectoryNotFound) { http.NotFound(writer, request) return } if err != nil { http.Error(writer, "Unable to read folder.", http.StatusInternalServerError) return } entries := make([]directoryEntry, 0, len(directory.Entries)) for _, entry := range directory.Entries { entryView := directoryEntry{ Name: entry.Name, Type: "File", Size: formatSize(entry.Size), Modified: entry.ModifiedAt.Local().Format("2006-01-02 15:04"), } if entry.IsDirectory { entryView.Type = "Folder" entryView.URL = browseURL(directory.Root.Name, joinRelativePath(directory.RelativePath, entry.Name)) } else { entryView.DownloadURL = fileURL(downloadPath, directory.Root.Name, joinRelativePath(directory.RelativePath, entry.Name)) if isVideoFile(entry.Name) { entryView.StreamURL = fileURL(streamPath, directory.Root.Name, joinRelativePath(directory.RelativePath, entry.Name)) } } entries = append(entries, entryView) } renderTemplate(writer, directoryTemplate, directoryPage{ UserEmail: user.Email, Breadcrumbs: breadcrumbLinks(directory.Root.Name, directory.RelativePath), Entries: entries, }) } func (app *application) handleFileDelivery(writer http.ResponseWriter, request *http.Request, download bool) { if request.Method != http.MethodGet && request.Method != http.MethodHead { writer.Header().Set("Allow", http.MethodGet+", "+http.MethodHead) http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) return } if app.browserError != nil { http.Error(writer, "Unable to load configured roots.", http.StatusInternalServerError) return } file, metadata, err := app.browser.OpenFile(request.URL.Query().Get("root"), request.URL.Query().Get("path")) if errors.Is(err, browser.ErrRootNotFound) || errors.Is(err, browser.ErrPathOutsideRoot) || errors.Is(err, browser.ErrDirectoryNotFound) { http.NotFound(writer, request) return } if err != nil { http.Error(writer, "Unable to read file.", http.StatusInternalServerError) return } defer func() { _ = file.Close() }() contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(metadata.Name))) if contentType == "" { contentType = "application/octet-stream" } dispositionType := "inline" if download { dispositionType = "attachment" } disposition := mime.FormatMediaType(dispositionType, map[string]string{"filename": metadata.Name}) writer.Header().Set("Content-Type", contentType) writer.Header().Set("Content-Disposition", disposition) http.ServeContent(writer, request, metadata.Name, metadata.ModifiedAt, file) } func (app *application) currentUser(writer http.ResponseWriter, request *http.Request) (auth.User, string, bool) { cookie, err := request.Cookie(sessionCookieName) if err != nil { http.Redirect(writer, request, loginPath, http.StatusSeeOther) return auth.User{}, "", false } user, err := app.store.LookupSession(cookie.Value) if err != nil { app.clearSessionCookie(writer) http.Redirect(writer, request, loginPath, http.StatusSeeOther) return auth.User{}, "", false } app.setSessionCookie(writer, cookie.Value) return user, cookie.Value, true } func (app *application) setSessionCookie(writer http.ResponseWriter, token string) { http.SetCookie(writer, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", Expires: time.Now().Add(app.store.SessionLifetime()), HttpOnly: true, Secure: app.secureCookies, SameSite: http.SameSiteLaxMode, }) } func (app *application) clearSessionCookie(writer http.ResponseWriter) { http.SetCookie(writer, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: app.secureCookies, SameSite: http.SameSiteLaxMode, }) } type rootPage struct { UserEmail string IsAdmin bool Roots []rootLink } type adminUsersPage struct { UserEmail string Users []auth.User Error string } type rootLink struct { Name string URL string } type directoryPage struct { UserEmail string Breadcrumbs []rootLink Entries []directoryEntry } type directoryEntry struct { Name string URL string DownloadURL string StreamURL string Type string Size string Modified string } func browseURL(rootName, relativePath string) string { return fileURL(browsePath, rootName, relativePath) } func fileURL(endpoint, rootName, relativePath string) string { query := url.Values{"root": {rootName}} if relativePath != "" { query.Set("path", relativePath) } return endpoint + "?" + query.Encode() } func breadcrumbLinks(rootName, relativePath string) []rootLink { breadcrumbs := []rootLink{{Name: rootName, URL: browseURL(rootName, "")}} pathSoFar := "" for _, part := range strings.Split(relativePath, "/") { if part == "" { continue } pathSoFar = joinRelativePath(pathSoFar, part) breadcrumbs = append(breadcrumbs, rootLink{Name: part, URL: browseURL(rootName, pathSoFar)}) } return breadcrumbs } func joinRelativePath(parent, child string) string { if parent == "" { return child } return path.Join(parent, child) } func formatSize(size int64) string { return strconv.FormatInt(size, 10) + " B" } func isVideoFile(name string) bool { return strings.HasPrefix(mime.TypeByExtension(strings.ToLower(filepath.Ext(name))), "video/") } func methodNotAllowed(writer http.ResponseWriter, allowedMethod string) { writer.Header().Set("Allow", allowedMethod) http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) } func renderLogin(writer http.ResponseWriter, status int, message string) { writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.WriteHeader(status) _ = loginTemplate.Execute(writer, struct{ Error string }{Error: message}) } func renderChangePassword(writer http.ResponseWriter, status int, message string) { writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.WriteHeader(status) _ = changePasswordTemplate.Execute(writer, struct{ Error string }{Error: message}) } func renderTemplate(writer http.ResponseWriter, pageTemplate *template.Template, data any) { writer.Header().Set("Content-Type", "text/html; charset=utf-8") if err := pageTemplate.Execute(writer, data); err != nil { http.Error(writer, "Unable to render page.", http.StatusInternalServerError) } }