Git Repository
Mini Video Library
Mini Video Library scannt lokale Medienordner, verwaltet Metadaten und Vorschaubilder und streamt kompatible Videos direkt per HTTP an den Browser.
HTTPS
https://zanvex.de/git/mini-video-library.gitinternal/themes/themes.go
Zum Verzeichnispackage themes
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
const maxArchiveSize = 20 << 20
var validID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)
var allowedExtensions = map[string]bool{".css": true, ".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".svg": true, ".woff": true, ".woff2": true}
type Manifest struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
}
type Manager struct{ Root string }
func New(root string) *Manager { return &Manager{Root: root} }
func (m *Manager) List() ([]Manifest, error) {
entries, err := os.ReadDir(m.Root)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var result []Manifest
for _, entry := range entries {
if !entry.IsDir() {
continue
}
manifest, err := readManifest(filepath.Join(m.Root, entry.Name(), "theme.json"))
if err == nil {
result = append(result, manifest)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result, nil
}
func (m *Manager) Install(reader io.ReaderAt, size int64) (Manifest, error) {
if size <= 0 || size > maxArchiveSize {
return Manifest{}, errors.New("theme ZIP must be between 1 byte and 20 MiB")
}
zr, err := zip.NewReader(reader, size)
if err != nil {
return Manifest{}, fmt.Errorf("invalid theme ZIP: %w", err)
}
var manifest Manifest
for _, file := range zr.File {
name, err := safeName(file.Name)
if err != nil {
return Manifest{}, err
}
if name == "theme.json" {
if file.UncompressedSize64 > 64<<10 {
return Manifest{}, errors.New("theme.json is too large")
}
r, err := file.Open()
if err != nil {
return Manifest{}, err
}
err = json.NewDecoder(io.LimitReader(r, 64<<10)).Decode(&manifest)
r.Close()
if err != nil {
return Manifest{}, fmt.Errorf("invalid theme.json: %w", err)
}
}
}
if !validID.MatchString(manifest.ID) || strings.TrimSpace(manifest.Name) == "" {
return Manifest{}, errors.New("theme.json requires a valid id and name")
}
if !hasFile(zr.File, "theme.css") {
return Manifest{}, errors.New("theme ZIP requires theme.css")
}
if err = os.MkdirAll(m.Root, 0755); err != nil {
return Manifest{}, err
}
tmp, err := os.MkdirTemp(m.Root, ".install-")
if err != nil {
return Manifest{}, err
}
defer os.RemoveAll(tmp)
var total uint64
for _, file := range zr.File {
name, err := safeName(file.Name)
if err != nil {
return Manifest{}, err
}
if file.FileInfo().IsDir() {
continue
}
total += file.UncompressedSize64
if total > maxArchiveSize {
return Manifest{}, errors.New("unpacked theme exceeds 20 MiB")
}
if name != "theme.json" && !allowedExtensions[strings.ToLower(filepath.Ext(name))] {
return Manifest{}, fmt.Errorf("file type not allowed: %s", name)
}
destination := filepath.Join(tmp, filepath.FromSlash(name))
if err = os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
return Manifest{}, err
}
in, err := file.Open()
if err != nil {
return Manifest{}, err
}
out, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err == nil {
_, err = io.Copy(out, io.LimitReader(in, int64(file.UncompressedSize64)+1))
out.Close()
}
in.Close()
if err != nil {
return Manifest{}, err
}
}
if err = filepath.WalkDir(tmp, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil || entry.IsDir() || strings.ToLower(filepath.Ext(path)) != ".css" {
return walkErr
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
css := strings.ToLower(string(data))
for _, forbidden := range []string{"@import", "http:", "https:", "url(//", "javascript:"} {
if strings.Contains(css, forbidden) {
return fmt.Errorf("external or executable CSS is not allowed: %s", filepath.Base(path))
}
}
return nil
}); err != nil {
return Manifest{}, err
}
destination := filepath.Join(m.Root, manifest.ID)
if err = os.RemoveAll(destination); err != nil {
return Manifest{}, err
}
if err = os.Rename(tmp, destination); err != nil {
return Manifest{}, err
}
return manifest, nil
}
func (m *Manager) Asset(themeID, name string) (string, error) {
if !validID.MatchString(themeID) {
return "", os.ErrNotExist
}
safe, err := safeName(name)
if err != nil {
return "", os.ErrNotExist
}
path := filepath.Join(m.Root, themeID, filepath.FromSlash(safe))
if _, err = os.Stat(path); err != nil {
return "", err
}
return path, nil
}
func readManifest(path string) (Manifest, error) {
var x Manifest
b, err := os.ReadFile(path)
if err == nil {
err = json.Unmarshal(b, &x)
}
return x, err
}
func hasFile(files []*zip.File, wanted string) bool {
for _, f := range files {
if strings.ReplaceAll(f.Name, "\\", "/") == wanted {
return true
}
}
return false
}
func safeName(name string) (string, error) {
name = strings.ReplaceAll(name, "\\", "/")
clean := filepath.ToSlash(filepath.Clean(name))
if clean == "." || strings.HasPrefix(clean, "../") || strings.HasPrefix(clean, "/") || filepath.IsAbs(name) {
return "", fmt.Errorf("unsafe theme path: %s", name)
}
return clean, nil
}