Browse Source

feat(files): add downloads and video streaming

main
Domagoj Zecevic 1 month ago
parent
commit
83fa87a44f
  1. 28
      .ai/HANDOFF.md
  2. 19
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 16
      README.md
  5. 60
      internal/browser/browser.go
  6. 62
      internal/browser/file_test.go
  7. 72
      internal/web/app.go
  8. 88
      internal/web/file_test.go

28
.ai/HANDOFF.md

@ -134,3 +134,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t
| Next Role | none | | Next Role | none |
--- ---
### T-004 — dev — 2026-09-24T09:59:37Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added root-confined raw file downloads and HTTP Range video streaming without media conversion. |
| Files Changed | README.md, internal/browser/browser.go, internal/browser/file_test.go, internal/web/app.go, internal/web/file_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(files): add downloads and video streaming` |
| Verdict | PASS |
| Blocking Findings | none |
| Next Role | human approval |
---
### T-004 — commit_task — 2026-09-24T10:00:28Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Approved the reviewed T-004 file-delivery 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(files): add downloads and video streaming` |
| Next Role | none |
---

19
.ai/REVIEW.md

@ -68,3 +68,22 @@ that both `..` traversal and an outward-pointing symlink receive a 404 from the
authenticated browser route. The server-rendered pages expose configured roots, authenticated browser route. The server-rendered pages expose configured roots,
nested folders, breadcrumbs, file metadata, and an inline vanilla-JavaScript nested folders, breadcrumbs, file metadata, and an inline vanilla-JavaScript
theme control backed by browser local storage. theme control backed by browser local storage.
## T-004 — 2026-09-24T09:59:37Z
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 the new download and stream endpoints reuse the
root-confined file opener, serve only regular files, and delegate byte-range
handling to Go's `http.ServeContent`. Tests verify the attachment's unmodified
bytes, content headers, `206 Partial Content`, `Content-Range`, and
`Accept-Ranges` behavior. No media processing code was added.

2
.ai/TASKS.md

@ -23,6 +23,6 @@ Command expectations:
| T-001 | Project scaffolding: Go module layout, root-config loading (file/env listing container paths + display names), Dockerfile skeleton, docker-compose example, `README.md` overview/setup skeleton | done | `go build ./...` succeeds; app boots and logs the configured roots from a sample config without panicking; `README.md` documents the config file/env format for declaring roots and the volume-mount + restart-to-add-root constraint | PASS: config source/precedence/validation tests; `go fmt ./...`, `go vet ./...`, `go test ./...`, and `go build -buildvcs=false ./...` in `golang:1.24`; Docker image build + sample-config startup-log smoke test | none | | T-001 | Project scaffolding: Go module layout, root-config loading (file/env listing container paths + display names), Dockerfile skeleton, docker-compose example, `README.md` overview/setup skeleton | done | `go build ./...` succeeds; app boots and logs the configured roots from a sample config without panicking; `README.md` documents the config file/env format for declaring roots and the volume-mount + restart-to-add-root constraint | PASS: config source/precedence/validation tests; `go fmt ./...`, `go vet ./...`, `go test ./...`, and `go build -buildvcs=false ./...` in `golang:1.24`; Docker image build + sample-config startup-log 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-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 | ready_for_implement | 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 | n/a | implement | | 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) | 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-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 |

16
README.md

@ -3,8 +3,8 @@
## Overview ## Overview
A lightweight, container-based file browser for named host folders mounted A lightweight, container-based file browser for named host folders mounted
read-only into a container. It provides login-gated root and folder browsing; read-only into a container. It provides login-gated browsing, downloads, and
file downloads and streaming are added in a later phase. native video streaming without server-side media processing.
## Getting Started ## Getting Started
@ -94,6 +94,18 @@ Use the theme button in the page header to switch between light and dark modes.
The preference is stored in the browser's local storage and is restored on the The preference is stored in the browser's local storage and is restored on the
next visit from that browser. next visit from that browser.
## Downloads and video streaming
Every listed file has a download link that returns its original bytes as an
attachment. Recognized video files also have a streaming link. The application
uses HTTP byte ranges for video seeking and resumable playback; it never
transcodes, encodes, thumbnails, or otherwise transforms media.
Your TLS reverse proxy must pass the request `Range` header through to the
application and preserve range responses such as `206 Partial Content`,
`Content-Range`, and `Accept-Ranges`. Do not buffer or replace these responses,
or native browser seeking will not work.
## AI Workflow ## AI Workflow
This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer. This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer.

60
internal/browser/browser.go

@ -36,6 +36,15 @@ type Directory struct {
Entries []Entry Entries []Entry
} }
// File describes a regular file that is safe to serve from a configured root.
type File struct {
Root config.Root
RelativePath string
Name string
Size int64
ModifiedAt time.Time
}
// Browser provides root-confined directory listings. // Browser provides root-confined directory listings.
type Browser struct { type Browser struct {
rootsByName map[string]config.Root rootsByName map[string]config.Root
@ -125,6 +134,57 @@ func (browser *Browser) List(rootName, relativePath string) (Directory, error) {
return Directory{Root: root, RelativePath: cleanRelativePath, Entries: listedEntries}, nil return Directory{Root: root, RelativePath: cleanRelativePath, Entries: listedEntries}, nil
} }
// OpenFile opens a regular file without allowing its path to escape the
// configured root through parent traversal or symlinks. The caller closes the
// returned file after streaming its raw bytes.
func (browser *Browser) OpenFile(rootName, relativePath string) (*os.File, File, error) {
root, exists := browser.rootsByName[rootName]
if !exists {
return nil, File{}, ErrRootNotFound
}
cleanRelativePath, err := cleanRelativePath(relativePath)
if err != nil || cleanRelativePath == "" {
if err != nil {
return nil, File{}, err
}
return nil, File{}, ErrDirectoryNotFound
}
resolvedRoot, err := filepath.EvalSymlinks(root.Path)
if err != nil {
return nil, File{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err)
}
rootInfo, err := os.Stat(resolvedRoot)
if err != nil || !rootInfo.IsDir() {
return nil, File{}, ErrDirectoryNotFound
}
candidate := filepath.Join(root.Path, filepath.FromSlash(cleanRelativePath))
resolvedCandidate, err := filepath.EvalSymlinks(candidate)
if err != nil {
return nil, File{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err)
}
if !isWithin(resolvedRoot, resolvedCandidate) {
return nil, File{}, ErrPathOutsideRoot
}
info, err := os.Stat(resolvedCandidate)
if err != nil || !info.Mode().IsRegular() {
return nil, File{}, ErrDirectoryNotFound
}
file, err := os.Open(resolvedCandidate)
if err != nil {
return nil, File{}, fmt.Errorf("open file: %w", err)
}
return file, File{
Root: root,
RelativePath: cleanRelativePath,
Name: filepath.Base(cleanRelativePath),
Size: info.Size(),
ModifiedAt: info.ModTime(),
}, nil
}
func cleanRelativePath(relativePath string) (string, error) { func cleanRelativePath(relativePath string) (string, error) {
if relativePath == "" { if relativePath == "" {
return "", nil return "", nil

62
internal/browser/file_test.go

@ -0,0 +1,62 @@
package browser
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
"webfilebrowser/internal/config"
)
func TestOpenFileReturnsOnlyARegularFileWithinTheConfiguredRoot(t *testing.T) {
rootPath := t.TempDir()
contents := []byte("byte-identical download")
if err := os.WriteFile(filepath.Join(rootPath, "report.txt"), contents, 0o600); err != nil {
t.Fatal(err)
}
browser, err := New([]config.Root{{Name: "Media", Path: rootPath}})
if err != nil {
t.Fatal(err)
}
file, metadata, err := browser.OpenFile("Media", "report.txt")
if err != nil {
t.Fatalf("OpenFile() error = %v", err)
}
t.Cleanup(func() { _ = file.Close() })
actual, err := io.ReadAll(file)
if err != nil {
t.Fatal(err)
}
if string(actual) != string(contents) {
t.Errorf("OpenFile() bytes = %q, want %q", actual, contents)
}
if metadata.Name != "report.txt" || metadata.Size != int64(len(contents)) {
t.Errorf("OpenFile() metadata = %#v, want report.txt with its byte size", metadata)
}
}
func TestOpenFileRejectsTraversalAndSymlinkEscapes(t *testing.T) {
rootPath := t.TempDir()
outsidePath := t.TempDir()
if err := os.WriteFile(filepath.Join(outsidePath, "secret.txt"), []byte("outside-root"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(outsidePath, "secret.txt"), filepath.Join(rootPath, "escape.txt")); err != nil {
t.Fatal(err)
}
browser, err := New([]config.Root{{Name: "Media", Path: rootPath}})
if err != nil {
t.Fatal(err)
}
for _, relativePath := range []string{"../" + filepath.Base(outsidePath) + "/secret.txt", "escape.txt"} {
t.Run(relativePath, func(t *testing.T) {
if _, _, err := browser.OpenFile("Media", relativePath); !errors.Is(err, ErrPathOutsideRoot) {
t.Errorf("OpenFile(%q) error = %v, want ErrPathOutsideRoot", relativePath, err)
}
})
}
}

72
internal/web/app.go

@ -4,9 +4,11 @@ package web
import ( import (
"errors" "errors"
"html/template" "html/template"
"mime"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -21,6 +23,8 @@ const (
logoutPath = "/logout" logoutPath = "/logout"
changePasswordPath = "/change-password" changePasswordPath = "/change-password"
browsePath = "/browse" browsePath = "/browse"
downloadPath = "/download"
streamPath = "/stream"
sessionCookieName = "webfilebrowser_session" sessionCookieName = "webfilebrowser_session"
) )
@ -41,7 +45,7 @@ var (
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></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></tr>{{else}}<tr><td colspan="4">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))
) )
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>
@ -111,6 +115,10 @@ func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Requ
app.handleRoots(writer, request, user) app.handleRoots(writer, request, user)
case browsePath: case browsePath:
app.handleBrowse(writer, request, user) app.handleBrowse(writer, request, user)
case downloadPath:
app.handleFileDelivery(writer, request, true)
case streamPath:
app.handleFileDelivery(writer, request, false)
default: default:
http.NotFound(writer, request) http.NotFound(writer, request)
} }
@ -235,6 +243,11 @@ func (app *application) handleBrowse(writer http.ResponseWriter, request *http.R
if entry.IsDirectory { if entry.IsDirectory {
entryView.Type = "Folder" entryView.Type = "Folder"
entryView.URL = browseURL(directory.Root.Name, joinRelativePath(directory.RelativePath, entry.Name)) 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) entries = append(entries, entryView)
} }
@ -245,6 +258,41 @@ func (app *application) handleBrowse(writer http.ResponseWriter, request *http.R
}) })
} }
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) { func (app *application) currentUser(writer http.ResponseWriter, request *http.Request) (auth.User, string, bool) {
cookie, err := request.Cookie(sessionCookieName) cookie, err := request.Cookie(sessionCookieName)
if err != nil { if err != nil {
@ -302,19 +350,25 @@ type directoryPage struct {
} }
type directoryEntry struct { type directoryEntry struct {
Name string Name string
URL string URL string
Type string DownloadURL string
Size string StreamURL string
Modified string Type string
Size string
Modified string
} }
func browseURL(rootName, relativePath string) string { func browseURL(rootName, relativePath string) string {
return fileURL(browsePath, rootName, relativePath)
}
func fileURL(endpoint, rootName, relativePath string) string {
query := url.Values{"root": {rootName}} query := url.Values{"root": {rootName}}
if relativePath != "" { if relativePath != "" {
query.Set("path", relativePath) query.Set("path", relativePath)
} }
return browsePath + "?" + query.Encode() return endpoint + "?" + query.Encode()
} }
func breadcrumbLinks(rootName, relativePath string) []rootLink { func breadcrumbLinks(rootName, relativePath string) []rootLink {
@ -341,6 +395,10 @@ func formatSize(size int64) string {
return strconv.FormatInt(size, 10) + " B" 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) { func methodNotAllowed(writer http.ResponseWriter, allowedMethod string) {
writer.Header().Set("Allow", allowedMethod) writer.Header().Set("Allow", allowedMethod)
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed)

88
internal/web/file_test.go

@ -0,0 +1,88 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDownloadServesByteIdenticalFileAsAttachment(t *testing.T) {
rootPath, handler := newBrowsingApplication(t)
contents := []byte("the exact source bytes\x00\xff")
if err := os.WriteFile(filepath.Join(rootPath, "report.txt"), contents, 0o600); err != nil {
t.Fatal(err)
}
cookie := completeSeedPasswordChange(t, handler)
response := getWithCookie(handler, "/download?root=Media&path=report.txt", cookie)
if response.Code != http.StatusOK {
t.Fatalf("GET /download status = %d, want %d", response.Code, http.StatusOK)
}
if !bytes.Equal(response.Body.Bytes(), contents) {
t.Errorf("GET /download bytes = %q, want %q", response.Body.Bytes(), contents)
}
if contentType := response.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") {
t.Errorf("GET /download Content-Type = %q, want text/plain", contentType)
}
if disposition := response.Header().Get("Content-Disposition"); !strings.HasPrefix(disposition, "attachment;") || !strings.Contains(disposition, "report.txt") {
t.Errorf("GET /download Content-Disposition = %q, want attachment filename", disposition)
}
}
func TestStreamHonorsVideoRangeRequests(t *testing.T) {
rootPath, handler := newBrowsingApplication(t)
contents := []byte("0123456789")
if err := os.WriteFile(filepath.Join(rootPath, "clip.mp4"), contents, 0o600); err != nil {
t.Fatal(err)
}
cookie := completeSeedPasswordChange(t, handler)
request := getRequestWithCookie("/stream?root=Media&path=clip.mp4", cookie)
request.Header.Set("Range", "bytes=2-5")
response := serveRequest(handler, request)
if response.Code != http.StatusPartialContent {
t.Fatalf("GET /stream Range status = %d, want %d", response.Code, http.StatusPartialContent)
}
if body := response.Body.String(); body != "2345" {
t.Errorf("GET /stream Range bytes = %q, want %q", body, "2345")
}
if contentRange := response.Header().Get("Content-Range"); contentRange != "bytes 2-5/10" {
t.Errorf("GET /stream Content-Range = %q, want %q", contentRange, "bytes 2-5/10")
}
if acceptRanges := response.Header().Get("Accept-Ranges"); acceptRanges != "bytes" {
t.Errorf("GET /stream Accept-Ranges = %q, want %q", acceptRanges, "bytes")
}
if contentType := response.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "video/mp4") {
t.Errorf("GET /stream Content-Type = %q, want video/mp4", contentType)
}
}
func TestFileDeliveryRoutesRequireAuthentication(t *testing.T) {
_, handler := newBrowsingApplication(t)
for _, target := range []string{"/download?root=Media&path=report.txt", "/stream?root=Media&path=clip.mp4"} {
t.Run(target, func(t *testing.T) {
response := serveRequest(handler, getRequestWithCookie(target, nil))
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != loginPath {
t.Errorf("GET %s status/location = (%d, %q), want (%d, %q)", target, response.Code, response.Header().Get("Location"), http.StatusSeeOther, loginPath)
}
})
}
}
func getRequestWithCookie(target string, cookie *http.Cookie) *http.Request {
request := httptest.NewRequest(http.MethodGet, target, nil)
if cookie != nil {
request.AddCookie(cookie)
}
return request
}
func serveRequest(handler http.Handler, request *http.Request) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
Loading…
Cancel
Save