You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
603 lines
24 KiB
603 lines
24 KiB
// 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(`<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><title>Sign in</title></head>
|
|
<body><main><h1>Sign in</h1>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}
|
|
<form method="post" action="/login"><label>Email <input type="email" name="email" required></label>
|
|
<label>Password <input type="password" name="password" required></label><button type="submit">Sign in</button></form></main></body></html>`))
|
|
changePasswordTemplate = template.Must(template.New("change-password").Parse(`<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><title>Change password</title></head>
|
|
<body><main><h1>Change your password</h1><p>You must choose a new password before continuing.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}
|
|
<form method="post" action="/change-password"><label>Current password <input type="password" name="current_password" required></label>
|
|
<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 + `
|
|
<main><header><h1>webFileBrowser</h1><button id="theme-toggle" type="button">Toggle theme</button></header>
|
|
<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 + `
|
|
<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>
|
|
<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>
|
|
: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 pageEnd = `<script>
|
|
(() => {
|
|
const key = "webfilebrowser-theme";
|
|
const button = document.getElementById("theme-toggle");
|
|
const apply = (theme) => {
|
|
document.documentElement.dataset.theme = theme;
|
|
button.textContent = theme === "dark" ? "Use light theme" : "Use dark theme";
|
|
};
|
|
const saved = localStorage.getItem(key);
|
|
apply(saved === "dark" ? "dark" : "light");
|
|
button.addEventListener("click", () => {
|
|
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
|
localStorage.setItem(key, next);
|
|
apply(next);
|
|
});
|
|
})();
|
|
</script></body></html>`
|
|
|
|
// 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)
|
|
}
|
|
|
|
}
|
|
|