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.

Projektseite ↗
HTTPShttps://zanvex.de/git/mini-video-library.git

internal/themes/themes.go

Zum Verzeichnis
package 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
}