Compare commits

..

No commits in common. "a94b7e208dc9bf73b9d10cc45cb6f9970627ee4a" and "d2d9418b8b7b8e9821ce911cc0d1d826b6d728d7" have entirely different histories.

15 changed files with 456 additions and 154 deletions

102
README.md
View File

@ -18,18 +18,6 @@ x86_64 and ARM Docker images of latest version: `oci.seedno.de/seednode/roulette
Dockerfile available [here](https://git.seedno.de/seednode/roulette/raw/branch/master/docker/Dockerfile). Dockerfile available [here](https://git.seedno.de/seednode/roulette/raw/branch/master/docker/Dockerfile).
## Caching
If the `-c|--cache` flag is passed, the indices of all specified paths will be cached on start.
This will slightly increase the delay before the application begins responding to requests, but should significantly speed up subsequent requests.
If any `include=`/`exclude=` filters are specified in a given request, the cache will be bypassed for that specific request.
The cache can be regenerated at any time by accessing the `/clear_cache` endpoint.
If `--cache-file` is set, the cache will be loaded from the specified file on start, and written to the file whenever it is re-generated.
## Filtering ## Filtering
You can provide a comma-delimited string of alphanumeric patterns to match via the `include=` query parameter, assuming the `-f|--filter` flag is enabled. You can provide a comma-delimited string of alphanumeric patterns to match via the `include=` query parameter, assuming the `-f|--filter` flag is enabled.
@ -44,39 +32,6 @@ You can also combine these two parameters, with exclusions taking priority over
Both filtering parameters ignore the file extension and full path; they only compare against the bare filename. Both filtering parameters ignore the file extension and full path; they only compare against the bare filename.
## Info
If the `-i|--info` flag is passed, six additional endpoints are registered.
The first of these—`/html` and `/json`—return the contents of the index, in HTML and JSON formats respectively.
If `--page-length` is also set, these can be viewed in paginated form by appending `/n`, e.g. `/html/5` for the fifth page.
This can prove useful when confirming whether the index is generated successfully, or whether a given file is in the index.
The remaining four endpoints—`/available_extensions`, `/enabled_extensions`, `/available_mime_types` and `/enabled_mime_types`—return information about the registered file types.
## Refresh
If a positive-value `refresh=<integer><unit>` query parameter is provided, the page will reload after that interval.
This can be used to generate a sort of slideshow of files.
Minimum accepted value is 500ms, as anything lower seems to cause inconsistent behavior. This might be changed in a future release.
Supported units are `ns`, `us`/`µs`, `ms`, `s`, `m`, and `h`.
## Russian
If the `--russian` flag is passed, everything functions exactly as you would expect.
That is, files will be deleted after being served. This is not a joke, you *will* lose data.
This uses `os.Remove()` and checks to ensure the specified file is inside one of the paths passed to `roulette`.
That said, this has not been tested to any real extent, so only pass this flag on systems you don't care about.
Enjoy!
## Sorting ## Sorting
You can specify a sorting pattern via the `sort=` query parameter, assuming the `-s|--sort` flag is enabled. You can specify a sorting pattern via the `sort=` query parameter, assuming the `-s|--sort` flag is enabled.
@ -99,6 +54,59 @@ If any other (or no) value is provided, the selected file will be random.
Note: These patterns require sequentially-numbered files matching the following pattern: `filename###.extension`. Note: These patterns require sequentially-numbered files matching the following pattern: `filename###.extension`.
## Refresh
If a positive-value `refresh=<integer><unit>` query parameter is provided, the page will reload after that interval.
This can be used to generate a sort of slideshow of files.
Minimum accepted value is 500ms, as anything lower seems to cause inconsistent behavior. This might be changed in a future release.
Supported units are `ns`, `us`/`µs`, `ms`, `s`, `m`, and `h`.
## Caching
If the `-c|--cache` flag is passed, the indices of all specified paths will be cached on start.
This will slightly increase the delay before the application begins responding to requests, but should significantly speed up subsequent requests.
If any `include=`/`exclude=` filters are specified in a given request, the cache will be bypassed for that specific request.
The cache can be regenerated at any time by accessing the `/clear_cache` endpoint.
If `--cache-file` is set, the cache will be loaded from the specified file on start, and written to the file whenever it is re-generated.
## Info
If the `-i|--info` flag is passed, six additional endpoints are registered.
The first of these—`/html` and `/json`—return the contents of the index, in HTML and JSON formats respectively.
If `--page-length` is also set, these can be viewed in paginated form by appending `/n`, e.g. `/html/5` for the fifth page.
This can prove useful when confirming whether the index is generated successfully, or whether a given file is in the index.
The remaining four endpoints—`/available_extensions`, `/enabled_extensions`, `/available_mime_types` and `/enabled_mime_types`—return information about the registered file types.
## Statistics
If the `--stats` flag is passed, an additional endpoint, `/stats`, is registered.
When accessed, this endpoint returns a JSON document listing every file served, along with the number of times it has been served, its filesize, and timestamps of when it was served.
If `--page-length` is also set, this can be viewed in paginated form by appending `/n`, e.g. `/stats/5` for the fifth page.
## Russian
If the `--russian` flag is passed, everything functions exactly as you would expect.
That is, files will be deleted after being served. This is not a joke, you *will* lose data.
This uses `os.Remove()` and checks to ensure the specified file is inside one of the paths passed to `roulette`.
That said, this has not been tested to any real extent, so only pass this flag on systems you don't care about.
Enjoy!
## Usage output ## Usage output
``` ```
Serves random media from the specified directories. Serves random media from the specified directories.
@ -114,7 +122,6 @@ Flags:
--cache-file string path to optional persistent cache file --cache-file string path to optional persistent cache file
-f, --filter enable filtering -f, --filter enable filtering
--flash enable support for shockwave flash files (via ruffle.rs) --flash enable support for shockwave flash files (via ruffle.rs)
--handlers display registered handlers (for debugging)
-h, --help help for roulette -h, --help help for roulette
--images enable support for image files --images enable support for image files
-i, --info expose informational endpoints -i, --info expose informational endpoints
@ -122,12 +129,13 @@ Flags:
--minimum-files uint32 skip directories with file counts below this value (default 1) --minimum-files uint32 skip directories with file counts below this value (default 1)
--page-length uint32 pagination length for statistics and debug pages --page-length uint32 pagination length for statistics and debug pages
-p, --port uint16 port to listen on (default 8080) -p, --port uint16 port to listen on (default 8080)
--prefix string path with which to prefix all listeners (for reverse proxying)
--profile register net/http/pprof handlers --profile register net/http/pprof handlers
-r, --recursive recurse into subdirectories -r, --recursive recurse into subdirectories
--refresh-interval string force refresh interval equal to this duration (minimum 500ms) --refresh-interval string force refresh interval equal to this duration (minimum 500ms)
--russian remove selected images after serving --russian remove selected images after serving
-s, --sort enable sorting -s, --sort enable sorting
--stats expose stats endpoint
--stats-file string path to optional persistent stats file
--text enable support for text files --text enable support for text files
-v, --verbose log accessed files and other information to stdout -v, --verbose log accessed files and other information to stdout
-V, --version display version and exit -V, --version display version and exit

View File

@ -152,5 +152,5 @@ func registerCacheHandlers(mux *httprouter.Router, args []string, cache *fileCac
cache.generate(args, formats) cache.generate(args, formats)
} }
register(mux, Prefix+"/clear_cache", serveCacheClear(args, cache, formats)) mux.GET("/clear_cache", serveCacheClear(args, cache, formats))
} }

View File

@ -73,6 +73,15 @@ func (splitPath *splitPath) decrement() {
splitPath.number = splitPath.number - 1 splitPath.number = splitPath.number - 1
} }
func contains(slice []string, value string) bool {
for _, v := range slice {
if v == value {
return true
}
}
return false
}
func humanReadableSize(bytes int) string { func humanReadableSize(bytes int) string {
const unit = 1000 const unit = 1000
@ -171,8 +180,8 @@ func appendPaths(path string, files *files, filters *filters, stats *scanStats,
return nil return nil
} }
func newFile(paths []string, filters *filters, sortOrder string, regexes *regexes, cache *fileCache, formats *types.Types) (string, error) { func newFile(paths []string, filters *filters, sortOrder string, regexes *regexes, index *fileCache, formats *types.Types) (string, error) {
path, err := pickFile(paths, filters, sortOrder, cache, formats) path, err := pickFile(paths, filters, sortOrder, index, formats)
if err != nil { if err != nil {
return "", nil return "", nil
} }
@ -505,6 +514,24 @@ func fileList(paths []string, filters *filters, sort string, cache *fileCache, f
return fileList, false return fileList, false
} }
func cleanFilename(filename string) string {
return filename[:len(filename)-(len(filepath.Ext(filename))+3)]
}
func prepareDirectory(directory []string) []string {
_, first := filepath.Split(directory[0])
first = cleanFilename(first)
_, last := filepath.Split(directory[len(directory)-1])
last = cleanFilename(last)
if first == last {
return []string{directory[0]}
} else {
return directory
}
}
func prepareDirectories(files *files, sort string) []string { func prepareDirectories(files *files, sort string) []string {
directories := []string{} directories := []string{}
@ -516,8 +543,14 @@ func prepareDirectories(files *files, sort string) []string {
i++ i++
} }
for i := 0; i < len(keys); i++ { if sort == "asc" || sort == "desc" {
directories = append(directories, files.list[keys[i]]...) for i := 0; i < len(keys); i++ {
directories = append(directories, prepareDirectory(files.list[keys[i]])...)
}
} else {
for i := 0; i < len(keys); i++ {
directories = append(directories, files.list[keys[i]]...)
}
} }
return directories return directories
@ -527,12 +560,11 @@ func pickFile(args []string, filters *filters, sort string, cache *fileCache, fo
fileList, fromCache := fileList(args, filters, sort, cache, formats) fileList, fromCache := fileList(args, filters, sort, cache, formats)
fileCount := len(fileList) fileCount := len(fileList)
if fileCount < 1 { if fileCount < 1 {
return "", ErrNoMediaFound return "", ErrNoMediaFound
} }
r, err := rand.Int(rand.Reader, big.NewInt(int64(fileCount))) r, err := rand.Int(rand.Reader, big.NewInt(int64(fileCount-2)))
if err != nil { if err != nil {
return "", err return "", err
} }
@ -543,10 +575,9 @@ func pickFile(args []string, filters *filters, sort string, cache *fileCache, fo
} }
for i := 0; i < fileCount; i++ { for i := 0; i < fileCount; i++ {
switch { if val >= fileCount {
case val >= fileCount:
val = 0 val = 0
case val < fileCount-1: } else {
val++ val++
} }
@ -596,7 +627,7 @@ func normalizePath(path string) (string, error) {
return absolutePath, nil return absolutePath, nil
} }
func validatePaths(args []string, formats *types.Types) ([]string, error) { func normalizePaths(args []string, formats *types.Types) ([]string, error) {
var paths []string var paths []string
var pathList strings.Builder var pathList strings.Builder

View File

@ -65,7 +65,7 @@ func serveIndexHtml(args []string, cache *fileCache, paginate bool) httprouter.H
if Sorting { if Sorting {
shouldSort = "?sort=asc" shouldSort = "?sort=asc"
} }
htmlBody.WriteString(fmt.Sprintf("<tr><td><a href=\"%s%s%s%s\">%s</a></td></tr>\n", Prefix, mediaPrefix, v, shouldSort, v)) htmlBody.WriteString(fmt.Sprintf("<tr><td><a href=\"%s%s%s\">%s</a></td></tr>\n", mediaPrefix, v, shouldSort, v))
} }
} }
if PageLength != 0 { if PageLength != 0 {
@ -274,19 +274,19 @@ func serveEnabledMimeTypes(formats *types.Types) httprouter.Handle {
func registerInfoHandlers(mux *httprouter.Router, args []string, cache *fileCache, formats *types.Types) { func registerInfoHandlers(mux *httprouter.Router, args []string, cache *fileCache, formats *types.Types) {
if Cache { if Cache {
register(mux, Prefix+"/html/", serveIndexHtml(args, cache, false)) mux.GET("/html/", serveIndexHtml(args, cache, false))
if PageLength != 0 { if PageLength != 0 {
register(mux, Prefix+"/html/:page", serveIndexHtml(args, cache, true)) mux.GET("/html/:page", serveIndexHtml(args, cache, true))
} }
register(mux, Prefix+"/json", serveIndexJson(args, cache)) mux.GET("/json", serveIndexJson(args, cache))
if PageLength != 0 { if PageLength != 0 {
register(mux, Prefix+"/json/:page", serveIndexJson(args, cache)) mux.GET("/json/:page", serveIndexJson(args, cache))
} }
} }
register(mux, Prefix+"/available_extensions", serveAvailableExtensions()) mux.GET("/available_extensions", serveAvailableExtensions())
register(mux, Prefix+"/enabled_extensions", serveEnabledExtensions(formats)) mux.GET("/enabled_extensions", serveEnabledExtensions(formats))
register(mux, Prefix+"/available_mime_types", serveAvailableMimeTypes()) mux.GET("/available_mime_types", serveAvailableMimeTypes())
register(mux, Prefix+"/enabled_mime_types", serveEnabledMimeTypes(formats)) mux.GET("/enabled_mime_types", serveEnabledMimeTypes(formats))
} }

View File

@ -11,9 +11,9 @@ import (
) )
func registerProfileHandlers(mux *httprouter.Router) { func registerProfileHandlers(mux *httprouter.Router) {
mux.HandlerFunc("GET", Prefix+"/debug/pprof/", pprof.Index) mux.HandlerFunc("GET", "/debug/pprof/", pprof.Index)
mux.HandlerFunc("GET", Prefix+"/debug/pprof/cmdline", pprof.Cmdline) mux.HandlerFunc("GET", "/debug/pprof/cmdline", pprof.Cmdline)
mux.HandlerFunc("GET", Prefix+"/debug/pprof/profile", pprof.Profile) mux.HandlerFunc("GET", "/debug/pprof/profile", pprof.Profile)
mux.HandlerFunc("GET", Prefix+"/debug/pprof/symbol", pprof.Symbol) mux.HandlerFunc("GET", "/debug/pprof/symbol", pprof.Symbol)
mux.HandlerFunc("GET", Prefix+"/debug/pprof/trace", pprof.Trace) mux.HandlerFunc("GET", "/debug/pprof/trace", pprof.Trace)
} }

View File

@ -12,7 +12,7 @@ import (
) )
const ( const (
ReleaseVersion string = "0.82.0" ReleaseVersion string = "0.78.2"
) )
var ( var (
@ -23,19 +23,19 @@ var (
CacheFile string CacheFile string
Filtering bool Filtering bool
Flash bool Flash bool
Handlers bool
Images bool Images bool
Info bool Info bool
MaximumFileCount uint32 MaximumFileCount uint32
MinimumFileCount uint32 MinimumFileCount uint32
PageLength uint32 PageLength uint32
Port uint16 Port uint16
Prefix string
Profile bool Profile bool
Recursive bool Recursive bool
RefreshInterval string RefreshInterval string
Russian bool Russian bool
Sorting bool Sorting bool
Statistics bool
StatisticsFile string
Text bool Text bool
Verbose bool Verbose bool
Version bool Version bool
@ -81,19 +81,19 @@ func init() {
rootCmd.Flags().StringVar(&CacheFile, "cache-file", "", "path to optional persistent cache file") rootCmd.Flags().StringVar(&CacheFile, "cache-file", "", "path to optional persistent cache file")
rootCmd.Flags().BoolVarP(&Filtering, "filter", "f", false, "enable filtering") rootCmd.Flags().BoolVarP(&Filtering, "filter", "f", false, "enable filtering")
rootCmd.Flags().BoolVar(&Flash, "flash", false, "enable support for shockwave flash files (via ruffle.rs)") rootCmd.Flags().BoolVar(&Flash, "flash", false, "enable support for shockwave flash files (via ruffle.rs)")
rootCmd.Flags().BoolVar(&Handlers, "handlers", false, "display registered handlers (for debugging)")
rootCmd.Flags().BoolVar(&Images, "images", false, "enable support for image files") rootCmd.Flags().BoolVar(&Images, "images", false, "enable support for image files")
rootCmd.Flags().BoolVarP(&Info, "info", "i", false, "expose informational endpoints") rootCmd.Flags().BoolVarP(&Info, "info", "i", false, "expose informational endpoints")
rootCmd.Flags().Uint32Var(&MaximumFileCount, "maximum-files", 1<<32-1, "skip directories with file counts above this value") rootCmd.Flags().Uint32Var(&MaximumFileCount, "maximum-files", 1<<32-1, "skip directories with file counts above this value")
rootCmd.Flags().Uint32Var(&MinimumFileCount, "minimum-files", 1, "skip directories with file counts below this value") rootCmd.Flags().Uint32Var(&MinimumFileCount, "minimum-files", 1, "skip directories with file counts below this value")
rootCmd.Flags().Uint32Var(&PageLength, "page-length", 0, "pagination length for statistics and debug pages") rootCmd.Flags().Uint32Var(&PageLength, "page-length", 0, "pagination length for statistics and debug pages")
rootCmd.Flags().Uint16VarP(&Port, "port", "p", 8080, "port to listen on") rootCmd.Flags().Uint16VarP(&Port, "port", "p", 8080, "port to listen on")
rootCmd.Flags().StringVar(&Prefix, "prefix", "", "path with which to prefix all listeners (for reverse proxying)")
rootCmd.Flags().BoolVar(&Profile, "profile", false, "register net/http/pprof handlers") rootCmd.Flags().BoolVar(&Profile, "profile", false, "register net/http/pprof handlers")
rootCmd.Flags().BoolVarP(&Recursive, "recursive", "r", false, "recurse into subdirectories") rootCmd.Flags().BoolVarP(&Recursive, "recursive", "r", false, "recurse into subdirectories")
rootCmd.Flags().StringVar(&RefreshInterval, "refresh-interval", "", "force refresh interval equal to this duration (minimum 500ms)") rootCmd.Flags().StringVar(&RefreshInterval, "refresh-interval", "", "force refresh interval equal to this duration (minimum 500ms)")
rootCmd.Flags().BoolVar(&Russian, "russian", false, "remove selected images after serving") rootCmd.Flags().BoolVar(&Russian, "russian", false, "remove selected images after serving")
rootCmd.Flags().BoolVarP(&Sorting, "sort", "s", false, "enable sorting") rootCmd.Flags().BoolVarP(&Sorting, "sort", "s", false, "enable sorting")
rootCmd.Flags().BoolVar(&Statistics, "stats", false, "expose stats endpoint")
rootCmd.Flags().StringVar(&StatisticsFile, "stats-file", "", "path to optional persistent stats file")
rootCmd.Flags().BoolVar(&Text, "text", false, "enable support for text files") rootCmd.Flags().BoolVar(&Text, "text", false, "enable support for text files")
rootCmd.Flags().BoolVarP(&Verbose, "verbose", "v", false, "log accessed files and other information to stdout") rootCmd.Flags().BoolVarP(&Verbose, "verbose", "v", false, "log accessed files and other information to stdout")
rootCmd.Flags().BoolVarP(&Version, "version", "V", false, "display version and exit") rootCmd.Flags().BoolVarP(&Version, "version", "V", false, "display version and exit")

253
cmd/stats.go Normal file
View File

@ -0,0 +1,253 @@
/*
Copyright © 2023 Seednode <seednode@seedno.de>
*/
package cmd
import (
"encoding/gob"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/julienschmidt/httprouter"
"github.com/klauspost/compress/zstd"
)
type serveStats struct {
mutex sync.RWMutex
list []string
count map[string]uint32
size map[string]string
times map[string][]string
}
type publicServeStats struct {
List []string
Count map[string]uint32
Size map[string]string
Times map[string][]string
}
type timesServed struct {
File string
Served uint32
Size string
Times []string
}
func (stats *serveStats) incrementCounter(file string, timestamp time.Time, filesize string) {
stats.mutex.Lock()
stats.count[file]++
stats.times[file] = append(stats.times[file], timestamp.Format(logDate))
_, exists := stats.size[file]
if !exists {
stats.size[file] = filesize
}
if !contains(stats.list, file) {
stats.list = append(stats.list, file)
}
stats.mutex.Unlock()
}
func (stats *serveStats) Import(source *publicServeStats) {
stats.mutex.Lock()
copy(stats.list, source.List)
for k, v := range source.Count {
fmt.Printf("Setting count[%s] to %d\n", k, v)
stats.count[k] = v
}
for k, v := range source.Size {
fmt.Printf("Setting size[%s] to %v\n", k, v)
stats.size[k] = v
}
for k, v := range source.Times {
fmt.Printf("Setting times[%s] to %v\n", k, v)
stats.times[k] = v
}
stats.mutex.Unlock()
}
func (source *serveStats) Export() *publicServeStats {
source.mutex.RLock()
stats := &publicServeStats{
List: make([]string, len(source.list)),
Count: make(map[string]uint32, len(source.count)),
Size: make(map[string]string, len(source.size)),
Times: make(map[string][]string, len(source.times)),
}
copy(stats.List, source.list)
for k, v := range source.count {
stats.Count[k] = v
}
for k, v := range source.size {
stats.Size[k] = v
}
for k, v := range source.times {
stats.Times[k] = v
}
source.mutex.RUnlock()
return stats
}
func (stats *serveStats) exportFile(path string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer file.Close()
z, err := zstd.NewWriter(file)
if err != nil {
return err
}
defer z.Close()
enc := gob.NewEncoder(z)
err = enc.Encode(stats.Export())
if err != nil {
return err
}
return nil
}
func (stats *serveStats) importFile(path string) error {
file, err := os.OpenFile(path, os.O_RDONLY, 0600)
if err != nil {
return err
}
defer file.Close()
z, err := zstd.NewReader(file)
if err != nil {
return err
}
defer z.Close()
dec := gob.NewDecoder(z)
source := &publicServeStats{
List: []string{},
Count: make(map[string]uint32),
Size: make(map[string]string),
Times: make(map[string][]string),
}
err = dec.Decode(source)
if err != nil {
return err
}
stats.Import(source)
return nil
}
func (source *serveStats) listFiles(page int) ([]byte, error) {
stats := source.Export()
sort.SliceStable(stats.List, func(p, q int) bool {
return strings.ToLower(stats.List[p]) < strings.ToLower(stats.List[q])
})
var startIndex, stopIndex int
if page == -1 {
startIndex = 0
stopIndex = len(stats.List)
} else {
startIndex = ((page - 1) * int(PageLength))
stopIndex = (startIndex + int(PageLength))
}
if startIndex > len(stats.List)-1 {
return []byte("{}"), nil
}
if stopIndex > len(stats.List) {
stopIndex = len(stats.List)
}
a := make([]timesServed, (stopIndex - startIndex))
for k, v := range stats.List[startIndex:stopIndex] {
a[k] = timesServed{v, stats.Count[v], stats.Size[v], stats.Times[v]}
}
r, err := json.MarshalIndent(a, "", " ")
if err != nil {
return []byte{}, err
}
return r, nil
}
func serveStatsPage(args []string, stats *serveStats) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
startTime := time.Now()
page, err := strconv.Atoi(p.ByName("page"))
if err != nil || page == 0 {
page = -1
}
response, err := stats.listFiles(page)
if err != nil {
fmt.Println(err)
serverError(w, r, nil)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
if Verbose {
fmt.Printf("%s | Served statistics page (%s) to %s in %s\n",
startTime.Format(logDate),
humanReadableSize(len(response)),
realIP(r),
time.Since(startTime).Round(time.Microsecond),
)
}
if StatisticsFile != "" {
stats.exportFile(StatisticsFile)
}
}
}
func registerStatsHandlers(mux *httprouter.Router, args []string, stats *serveStats) {
mux.GET("/stats", serveStatsPage(args, stats))
if PageLength != 0 {
mux.GET("/stats/:page", serveStatsPage(args, stats))
}
}

View File

@ -12,12 +12,14 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
@ -38,11 +40,9 @@ const (
timeout time.Duration = 10 * time.Second timeout time.Duration = 10 * time.Second
) )
func serveStaticFile(paths []string, cache *fileCache) httprouter.Handle { func serveStaticFile(paths []string, stats *serveStats, cache *fileCache) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
prefix := Prefix + sourcePrefix path := strings.TrimPrefix(r.URL.Path, sourcePrefix)
path := strings.TrimPrefix(r.URL.Path, prefix)
prefixedFilePath, err := stripQueryParams(path) prefixedFilePath, err := stripQueryParams(path)
if err != nil { if err != nil {
@ -53,7 +53,7 @@ func serveStaticFile(paths []string, cache *fileCache) httprouter.Handle {
return return
} }
filePath, err := filepath.EvalSymlinks(strings.TrimPrefix(prefixedFilePath, prefix)) filePath, err := filepath.EvalSymlinks(strings.TrimPrefix(prefixedFilePath, sourcePrefix))
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
@ -122,6 +122,11 @@ func serveStaticFile(paths []string, cache *fileCache) httprouter.Handle {
time.Since(startTime).Round(time.Microsecond), time.Since(startTime).Round(time.Microsecond),
) )
} }
if Statistics {
stats.incrementCounter(filePath, startTime, fileSize)
}
} }
} }
@ -136,7 +141,7 @@ func serveRoot(paths []string, regexes *regexes, cache *fileCache, formats *type
return return
} }
strippedRefererUri := strings.TrimPrefix(refererUri, Prefix+mediaPrefix) strippedRefererUri := strings.TrimPrefix(refererUri, mediaPrefix)
filters := &filters{ filters := &filters{
included: splitQueryParams(r.URL.Query().Get("include"), regexes), included: splitQueryParams(r.URL.Query().Get("include"), regexes),
@ -189,9 +194,8 @@ func serveRoot(paths []string, regexes *regexes, cache *fileCache, formats *type
queryParams := generateQueryParams(filters, sortOrder, refreshInterval) queryParams := generateQueryParams(filters, sortOrder, refreshInterval)
newUrl := fmt.Sprintf("http://%s%s%s%s", newUrl := fmt.Sprintf("http://%s%s%s",
r.Host, r.Host,
Prefix,
preparePath(filePath), preparePath(filePath),
queryParams, queryParams,
) )
@ -208,7 +212,7 @@ func serveMedia(paths []string, regexes *regexes, formats *types.Types) httprout
sortOrder := sortOrder(r) sortOrder := sortOrder(r)
path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, Prefix), mediaPrefix) path := strings.TrimPrefix(r.URL.Path, mediaPrefix)
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
path = strings.TrimPrefix(path, "/") path = strings.TrimPrefix(path, "/")
@ -243,7 +247,7 @@ func serveMedia(paths []string, regexes *regexes, formats *types.Types) httprout
return return
} }
fileUri := Prefix + "/" + generateFileUri(path) fileUri := generateFileUri(path)
fileName := filepath.Base(path) fileName := filepath.Base(path)
@ -251,20 +255,20 @@ func serveMedia(paths []string, regexes *regexes, formats *types.Types) httprout
refreshTimer, refreshInterval := refreshInterval(r) refreshTimer, refreshInterval := refreshInterval(r)
rootUrl := Prefix + "/" + generateQueryParams(filters, sortOrder, refreshInterval) queryParams := generateQueryParams(filters, sortOrder, refreshInterval)
var htmlBody strings.Builder var htmlBody strings.Builder
htmlBody.WriteString(`<!DOCTYPE html><html lang="en"><head>`) htmlBody.WriteString(`<!DOCTYPE html><html lang="en"><head>`)
htmlBody.WriteString(faviconHtml) htmlBody.WriteString(faviconHtml)
htmlBody.WriteString(fmt.Sprintf(`<style>%s</style>`, fileType.Css())) htmlBody.WriteString(fmt.Sprintf(`<style>%s</style>`, fileType.Css()))
htmlBody.WriteString((fileType.Title(rootUrl, fileUri, path, fileName, Prefix, mimeType))) htmlBody.WriteString((fileType.Title(queryParams, fileUri, path, fileName, mimeType)))
htmlBody.WriteString(`</head><body>`) htmlBody.WriteString(`</head><body>`)
if refreshInterval != "0ms" { if refreshInterval != "0ms" {
htmlBody.WriteString(fmt.Sprintf("<script>window.onload = function(){setInterval(function(){window.location.href = '%s';}, %d);};</script>", htmlBody.WriteString(fmt.Sprintf("<script>window.onload = function(){setInterval(function(){window.location.href = '/%s';}, %d);};</script>",
rootUrl, queryParams,
refreshTimer)) refreshTimer))
} }
htmlBody.WriteString((fileType.Body(rootUrl, fileUri, path, fileName, Prefix, mimeType))) htmlBody.WriteString((fileType.Body(queryParams, fileUri, path, fileName, mimeType)))
htmlBody.WriteString(`</body></html>`) htmlBody.WriteString(`</body></html>`)
_, err = io.WriteString(w, gohtml.Format(htmlBody.String())) _, err = io.WriteString(w, gohtml.Format(htmlBody.String()))
@ -288,25 +292,6 @@ func serveVersion() httprouter.Handle {
} }
} }
func register(mux *httprouter.Router, path string, handle httprouter.Handle) {
mux.GET(path, handle)
if Handlers {
fmt.Printf("Registered handler for path %s\n", path)
}
}
func redirectRoot() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
newUrl := fmt.Sprintf("http://%s%s",
r.Host,
Prefix,
)
http.Redirect(w, r, newUrl, RedirectStatusCode)
}
}
func ServePage(args []string) error { func ServePage(args []string) error {
timeZone := os.Getenv("TZ") timeZone := os.Getenv("TZ")
if timeZone != "" { if timeZone != "" {
@ -354,7 +339,7 @@ func ServePage(args []string) error {
formats.Add(images.New()) formats.Add(images.New())
} }
paths, err := validatePaths(args, formats) paths, err := normalizePaths(args, formats)
if err != nil { if err != nil {
return err return err
} }
@ -383,25 +368,27 @@ func ServePage(args []string) error {
WriteTimeout: 5 * time.Minute, WriteTimeout: 5 * time.Minute,
} }
mux.PanicHandler = serverErrorHandler() stats := &serveStats{
mutex: sync.RWMutex{},
Prefix = strings.TrimSuffix(Prefix, "/") list: []string{},
count: make(map[string]uint32),
register(mux, Prefix+"/", serveRoot(paths, regexes, cache, formats)) size: make(map[string]string),
times: make(map[string][]string),
if Prefix != "" {
register(mux, "/", redirectRoot())
} }
register(mux, Prefix+"/favicons/*favicon", serveFavicons()) mux.PanicHandler = serverErrorHandler()
register(mux, Prefix+"/favicon.ico", serveFavicons()) mux.GET("/", serveRoot(paths, regexes, cache, formats))
register(mux, Prefix+mediaPrefix+"/*media", serveMedia(paths, regexes, formats)) mux.GET("/favicons/*favicon", serveFavicons())
register(mux, Prefix+sourcePrefix+"/*static", serveStaticFile(paths, cache)) mux.GET("/favicon.ico", serveFavicons())
register(mux, Prefix+"/version", serveVersion()) mux.GET(mediaPrefix+"/*media", serveMedia(paths, regexes, formats))
mux.GET(sourcePrefix+"/*static", serveStaticFile(paths, stats, cache))
mux.GET("/version", serveVersion())
if Cache { if Cache {
registerCacheHandlers(mux, args, cache, formats) registerCacheHandlers(mux, args, cache, formats)
@ -419,6 +406,33 @@ func ServePage(args []string) error {
fmt.Printf("WARNING! Files *will* be deleted after serving!\n\n") fmt.Printf("WARNING! Files *will* be deleted after serving!\n\n")
} }
if Statistics {
if StatisticsFile != "" {
StatisticsFile, err = normalizePath(StatisticsFile)
if err != nil {
return err
}
err := stats.importFile(StatisticsFile)
if err != nil {
return err
}
gracefulShutdown := make(chan os.Signal, 1)
signal.Notify(gracefulShutdown, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-gracefulShutdown
stats.exportFile(StatisticsFile)
os.Exit(0)
}()
}
registerStatsHandlers(mux, args, stats)
}
err = srv.ListenAndServe() err = srv.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) { if !errors.Is(err, http.ErrServerClosed) {
return err return err

Binary file not shown.

View File

@ -22,13 +22,13 @@ func (t Format) Css() string {
return css.String() return css.String()
} }
func (t Format) Title(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Title(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<title>%s</title>`, fileName) return fmt.Sprintf(`<title>%s</title>`, fileName)
} }
func (t Format) Body(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Body(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<a href="%s"><audio controls autoplay loop preload="auto"><source src="%s" type="%s" alt="Roulette selected: %s">Your browser does not support the audio tag.</audio></a>`, return fmt.Sprintf(`<a href="/%s"><audio controls autoplay loop preload="auto"><source src="%s" type="%s" alt="Roulette selected: %s">Your browser does not support the audio tag.</audio></a>`,
rootUrl, queryParams,
fileUri, fileUri,
mime, mime,
fileName) fileName)

View File

@ -22,15 +22,15 @@ func (t Format) Css() string {
return css.String() return css.String()
} }
func (t Format) Title(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Title(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<title>%s</title>`, fileName) return fmt.Sprintf(`<title>%s</title>`, fileName)
} }
func (t Format) Body(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Body(queryParams, fileUri, filePath, fileName, mime string) string {
var html strings.Builder var html strings.Builder
html.WriteString(fmt.Sprintf(`<script src="https://unpkg.com/@ruffle-rs/ruffle"></script><script>window.RufflePlayer.config = {autoplay:"on"};</script><embed src="%s"></embed>`, fileUri)) html.WriteString(fmt.Sprintf(`<script src="https://unpkg.com/@ruffle-rs/ruffle"></script><script>window.RufflePlayer.config = {autoplay:"on"};</script><embed src="%s"></embed>`, fileUri))
html.WriteString(fmt.Sprintf(`<br /><button onclick="window.location.href = '%s';">Next</button>`, rootUrl)) html.WriteString(fmt.Sprintf(`<br /><button onclick="window.location.href = '/%s';">Next</button>`, queryParams))
return html.String() return html.String()
} }

View File

@ -37,7 +37,7 @@ func (t Format) Css() string {
return css.String() return css.String()
} }
func (t Format) Title(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Title(queryParams, fileUri, filePath, fileName, mime string) string {
dimensions, err := ImageDimensions(filePath) dimensions, err := ImageDimensions(filePath)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
@ -49,14 +49,14 @@ func (t Format) Title(rootUrl, fileUri, filePath, fileName, prefix, mime string)
dimensions.height) dimensions.height)
} }
func (t Format) Body(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Body(queryParams, fileUri, filePath, fileName, mime string) string {
dimensions, err := ImageDimensions(filePath) dimensions, err := ImageDimensions(filePath)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
} }
return fmt.Sprintf(`<a href="%s"><img src="%s" width="%d" height="%d" type="%s" alt="Roulette selected: %s"></a>`, return fmt.Sprintf(`<a href="/%s"><img src="%s" width="%d" height="%d" type="%s" alt="Roulette selected: %s"></a>`,
rootUrl, queryParams,
fileUri, fileUri,
dimensions.width, dimensions.width,
dimensions.height, dimensions.height,

View File

@ -27,18 +27,18 @@ func (t Format) Css() string {
return css.String() return css.String()
} }
func (t Format) Title(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Title(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<title>%s</title>`, fileName) return fmt.Sprintf(`<title>%s</title>`, fileName)
} }
func (t Format) Body(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Body(queryParams, fileUri, filePath, fileName, mime string) string {
body, err := os.ReadFile(filePath) body, err := os.ReadFile(filePath)
if err != nil { if err != nil {
body = []byte{} body = []byte{}
} }
return fmt.Sprintf(`<a href="%s"><textarea autofocus readonly>%s</textarea></a>`, return fmt.Sprintf(`<a href="/%s"><textarea autofocus readonly>%s</textarea></a>`,
rootUrl, queryParams,
body) body)
} }
@ -46,27 +46,23 @@ func (t Format) Extensions() map[string]string {
return map[string]string{ return map[string]string{
`.css`: `text/css`, `.css`: `text/css`,
`.csv`: `text/csv`, `.csv`: `text/csv`,
`.htm`: `text/html`,
`.html`: `text/html`,
`.js`: `text/javascript`, `.js`: `text/javascript`,
`.json`: `application/json`, `.json`: `application/json`,
`.md`: `text/markdown`, `.md`: `text/markdown`,
`.ps1`: `text/plain`,
`.sh`: `application./x-sh`,
`.toml`: `application/toml`,
`.txt`: `text/plain`, `.txt`: `text/plain`,
`.xml`: `application/xml`, `.xml`: `application/xml`,
`.yml`: `application/yaml`,
`.yaml`: `application/yaml`,
} }
} }
func (t Format) MimeTypes() []string { func (t Format) MimeTypes() []string {
return []string{ return []string{
`application/json`, `application/json`,
`application/toml`,
`application/xml`, `application/xml`,
`application/yaml`,
`text/css`, `text/css`,
`text/csv`, `text/csv`,
`text/html`,
`text/javascript`, `text/javascript`,
`text/plain`, `text/plain`,
`text/plain; charset=utf-8`, `text/plain; charset=utf-8`,

View File

@ -20,8 +20,8 @@ var SupportedFormats = &Types{
type Type interface { type Type interface {
Css() string Css() string
Title(rootUrl, fileUri, filePath, fileName, prefix, mime string) string Title(queryParams, fileUri, filePath, fileName, mime string) string
Body(rootUrl, fileUri, filePath, fileName, prefix, mime string) string Body(queryParams, fileUri, filePath, fileName, mime string) string
Extensions() map[string]string Extensions() map[string]string
MimeTypes() []string MimeTypes() []string
Validate(filePath string) bool Validate(filePath string) bool

View File

@ -24,13 +24,13 @@ func (t Format) Css() string {
return css.String() return css.String()
} }
func (t Format) Title(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Title(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<title>%s</title>`, fileName) return fmt.Sprintf(`<title>%s</title>`, fileName)
} }
func (t Format) Body(rootUrl, fileUri, filePath, fileName, prefix, mime string) string { func (t Format) Body(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<a href="%s"><video controls autoplay loop preload="auto"><source src="%s" type="%s" alt="Roulette selected: %s">Your browser does not support the video tag.</video></a>`, return fmt.Sprintf(`<a href="/%s"><video controls autoplay loop preload="auto"><source src="%s" type="%s" alt="Roulette selected: %s">Your browser does not support the video tag.</video></a>`,
rootUrl, queryParams,
fileUri, fileUri,
mime, mime,
fileName) fileName)