// Package web provides the login-gated HTTP surface for the application. package web import ( "errors" "html/template" "net/http" "net/url" "path" "strconv" "strings" "time" "webfilebrowser/internal/auth" "webfilebrowser/internal/browser" "webfilebrowser/internal/config" ) const ( loginPath = "/login" logoutPath = "/logout" changePasswordPath = "/change-password" browsePath = "/browse" sessionCookieName = "webfilebrowser_session" ) 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}}.

Roots

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

webFileBrowser

Signed in as {{.UserEmail}}.

{{range .Entries}}{{else}}{{end}}
NameTypeSizeModified
{{if .URL}}{{.Name}}{{else}}{{.Name}}{{end}}{{.Type}}{{.Size}}{{.Modified}}
This folder is empty.
` + 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. func New(store *auth.Store, roots ...config.Root) http.Handler { fileBrowser, err := browser.New(roots) return &application{store: store, browser: fileBrowser, browserError: err} } type application struct { store *auth.Store browser *browser.Browser browserError error } 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 } switch request.URL.Path { case "/": app.handleRoots(writer, request, user) case browsePath: app.handleBrowse(writer, request, user) 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, Roots: roots}) } 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)) } entries = append(entries, entryView) } renderTemplate(writer, directoryTemplate, directoryPage{ UserEmail: user.Email, Breadcrumbs: breadcrumbLinks(directory.Root.Name, directory.RelativePath), Entries: entries, }) } 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: true, SameSite: http.SameSiteLaxMode, }) } func (app *application) clearSessionCookie(writer http.ResponseWriter) { http.SetCookie(writer, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } type rootPage struct { UserEmail string Roots []rootLink } type rootLink struct { Name string URL string } type directoryPage struct { UserEmail string Breadcrumbs []rootLink Entries []directoryEntry } type directoryEntry struct { Name string URL string Type string Size string Modified string } func browseURL(rootName, relativePath string) string { query := url.Values{"root": {rootName}} if relativePath != "" { query.Set("path", relativePath) } return browsePath + "?" + 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 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) } }