// 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
{{range .Roots}}- {{.Name}}
{{else}}- No roots are configured.
{{end}}
` + signOutForm + pageEnd))
directoryTemplate = template.Must(template.New("directory").Parse(pageStart + `
webFileBrowser
Signed in as {{.UserEmail}}.
| Name | Type | Size | Modified | Actions |
{{range .Entries}}| {{if .URL}}{{.Name}}{{else}}{{.Name}}{{end}} | {{.Type}} | {{.Size}} | {{.Modified}} | {{if .DownloadURL}}Download{{end}}{{if .StreamURL}} ยท Stream{{end}} |
{{else}}| This folder is empty. |
{{end}}
` + signOutForm + pageEnd))
adminUsersTemplate = template.Must(template.New("admin-users").Parse(pageStart + `
User management
Signed in as {{.UserEmail}}. Back to roots
{{if .Error}}{{.Error}}
{{end}}
Users
| Email | Role | Status | Password | Actions |
{{range .Users}}| {{.Email}} | {{.Role}} | {{if .Disabled}}Disabled{{else}}Enabled{{end}} | {{if .MustChangePassword}}Change required{{else}}Current{{end}} | |
{{else}}| No users exist. |
{{end}}
` + 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)
}
}