// Package web renders HTML pages for the player-server.
//
// The share-page renderer encapsulates the templating concern that used
// to live inline in the api package: opening the static share.html file,
// replacing the SHARE_MEDIA placeholder with marshaled JSON metadata,
// and reporting the file's ModTime for cache validators.
//
// Keeping this logic here lets HTTP handlers stay focused on routing and
// error translation (Separation of Concerns) and stops them reaching
// through a file-system abstraction (Law of Demeter).
package web
import (
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strings"
"time"
)
// MaxFileNameLength is the maximum number of runes kept from a media filename
// before it is truncated. Filenames beyond this limit can cause unbounded
// memory growth when embedded in the share-page HTML, so they are silently
// capped here. 255 is a common filesystem limit and a reasonable upper bound
// for display purposes.
const MaxFileNameLength = 255
// ShareMediaPlaceholder is the HTML comment that gets substituted with
// the JSON-encoded share metadata inside share.html.
const ShareMediaPlaceholder = ""
// shareTemplateName is the filename looked up in the static FS.
const shareTemplateName = "share.html"
// SharePageRenderer renders the public share landing page by inlining
// share metadata into a static HTML template.
//
// A renderer captures the file system and the placeholder it works with
// so callers (HTTP handlers) only need to pass the data to inject.
type SharePageRenderer struct {
fs http.FileSystem
template string
placeholder string
}
// NewSharePageRenderer builds a renderer backed by the given file system.
// The file system must contain share.html. The placeholder defaults to
// ShareMediaPlaceholder.
func NewSharePageRenderer(fs http.FileSystem) *SharePageRenderer {
return &SharePageRenderer{
fs: fs,
template: shareTemplateName,
placeholder: ShareMediaPlaceholder,
}
}
// RenderedPage carries the bytes to serve along with the source template's
// modification time (used for HTTP cache validators in ServeContent).
type RenderedPage struct {
HTML string
ModTime time.Time
Name string
}
// Render reads the share template from the file system, injects the
// JSON-encoded data in place of the placeholder, and returns the result.
//
// The caller (an HTTP handler) is responsible for turning errors into
// appropriate HTTP status codes; this package stays transport-agnostic.
func (r *SharePageRenderer) Render(data any) (RenderedPage, error) {
if r == nil || r.fs == nil {
return RenderedPage{}, fmt.Errorf("share renderer not configured")
}
f, err := r.fs.Open(r.template)
if err != nil {
return RenderedPage{}, fmt.Errorf("open share template: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return RenderedPage{}, fmt.Errorf("stat share template: %w", err)
}
var buf strings.Builder
if _, err := io.Copy(&buf, f); err != nil {
return RenderedPage{}, fmt.Errorf("read share template: %w", err)
}
rendered, err := injectShareMedia(buf.String(), r.placeholder, data)
if err != nil {
return RenderedPage{}, err
}
return RenderedPage{
HTML: rendered,
ModTime: stat.ModTime(),
Name: r.template,
}, nil
}
// SanitizeFileName truncates s to MaxFileNameLength runes and HTML-escapes
// the result. Both steps protect against DoS via enormous filenames and
// against HTML injection when the filename is embedded in a page attribute
// or element text context outside of the JSON-encoded script block.
func SanitizeFileName(s string) string {
runes := []rune(s)
if len(runes) > MaxFileNameLength {
runes = runes[:MaxFileNameLength]
}
return html.EscapeString(string(runes))
}
// injectShareMedia replaces placeholder with the JSON-encoded form of
// data, returning the new HTML. It is private to keep this package's
// surface small: callers are expected to go through SharePageRenderer.
//
// Go's encoding/json already escapes <, > and & as Unicode escapes inside
// string values, so the JSON blob is safe to embed in a