Compare commits

...

6 Commits

14 changed files with 385 additions and 358 deletions

View File

@ -78,17 +78,16 @@ The cache can be regenerated at any time by accessing the `/clear_cache` endpoin
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. 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.
If the `-i|--index` flag is passed, two additional endpoints—`/html` and `/json`—are registered.
When accessed, these endpoints return the contents of the index, in HTML and JSON formats respectively. This can prove useful when confirming whether the index is generated successfully, or whether a given file is in the index.
## Statistics ## Statistics
If the `--stats` flag is passed, an additional endpoint, `/stats`, is registered. 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. 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.
## Debug
If the `-d|--debug` flag is passed, two additional endpoints—`/html` and `/json`—are registered.
When accessed, these endpoints return the contents of the index, in HTML and JSON formats respectively. This can prove useful when confirming whether the index is generated successfully, or whether a given file is in the index.
## Russian ## Russian
If the `--russian` flag is passed, everything functions exactly as you would expect. If the `--russian` flag is passed, everything functions exactly as you would expect.
@ -113,10 +112,11 @@ Flags:
-b, --bind string address to bind to (default "0.0.0.0") -b, --bind string address to bind to (default "0.0.0.0")
-c, --cache generate directory cache at startup -c, --cache generate directory cache at startup
--cache-file string path to optional persistent cache file --cache-file string path to optional persistent cache file
-d, --debug expose debug endpoint
-f, --filter enable filtering -f, --filter enable filtering
--flash enable support for shockwave flash files (via ruffle.rs) (default true)
-h, --help help for roulette -h, --help help for roulette
--images enable support for image files (default true) --images enable support for image files (default true)
-i, --index expose index endpoints
--maximum-files uint skip directories with file counts above this value (default 18446744073709551615) --maximum-files uint skip directories with file counts above this value (default 18446744073709551615)
--minimum-files uint skip directories with file counts below this value (default 1) --minimum-files uint skip directories with file counts below this value (default 1)
--page-length uint pagination length for statistics and debug pages --page-length uint pagination length for statistics and debug pages

View File

@ -1,188 +0,0 @@
/*
Copyright © 2023 Seednode <seednode@seedno.de>
*/
package cmd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/julienschmidt/httprouter"
"github.com/yosssi/gohtml"
)
func serveDebugHtml(args []string, index *Index, paginate bool) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "text/html")
startTime := time.Now()
indexDump := index.Index()
fileCount := len(indexDump)
var startIndex, stopIndex int
page, err := strconv.Atoi(p.ByName("page"))
if err != nil || page <= 0 {
startIndex = 0
stopIndex = fileCount
} else {
startIndex = ((page - 1) * int(pageLength))
stopIndex = (startIndex + int(pageLength))
}
if startIndex > (fileCount - 1) {
indexDump = []string{}
}
if stopIndex > fileCount {
stopIndex = fileCount
}
sort.SliceStable(indexDump, func(p, q int) bool {
return strings.ToLower(indexDump[p]) < strings.ToLower(indexDump[q])
})
var htmlBody strings.Builder
htmlBody.WriteString(`<!DOCTYPE html><html lang="en"><head>`)
htmlBody.WriteString(FaviconHtml)
htmlBody.WriteString(`<style>a{text-decoration:none;height:100%;width:100%;color:inherit;cursor:pointer}`)
htmlBody.WriteString(`table,td,tr{border:1px solid black;border-collapse:collapse}td{white-space:nowrap;padding:.5em}</style>`)
htmlBody.WriteString(fmt.Sprintf("<title>Index contains %d files</title></head><body><table>", fileCount))
if len(indexDump) > 0 {
for _, v := range indexDump[startIndex:stopIndex] {
var shouldSort = ""
if sorting {
shouldSort = "?sort=asc"
}
htmlBody.WriteString(fmt.Sprintf("<tr><td><a href=\"%s%s%s\">%s</a></td></tr>\n", MediaPrefix, v, shouldSort, v))
}
}
if pageLength != 0 {
var firstPage int = 1
var lastPage int
if fileCount%int(pageLength) == 0 {
lastPage = fileCount / int(pageLength)
} else {
lastPage = (fileCount / int(pageLength)) + 1
}
if paginate {
var prevStatus, nextStatus string = "", ""
if page <= 1 {
prevStatus = " disabled"
}
if page >= lastPage {
nextStatus = " disabled"
}
prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := page + 1
if nextPage > lastPage {
nextPage = fileCount / int(pageLength)
}
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\">First</button>",
firstPage))
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\"%s>Prev</button>",
prevPage,
prevStatus))
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\"%s>Next</button>",
nextPage,
nextStatus))
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\">Last</button>",
lastPage))
}
}
htmlBody.WriteString(`</table></body></html>`)
b, err := io.WriteString(w, gohtml.Format(htmlBody.String()))
if err != nil {
return
}
if verbose {
fmt.Printf("%s | Served HTML debug page (%s) to %s in %s\n",
startTime.Format(LogDate),
humanReadableSize(b),
realIP(r),
time.Since(startTime).Round(time.Microsecond),
)
}
}
}
func serveDebugJson(args []string, index *Index) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
startTime := time.Now()
indexDump := index.Index()
fileCount := len(indexDump)
sort.SliceStable(indexDump, func(p, q int) bool {
return strings.ToLower(indexDump[p]) < strings.ToLower(indexDump[q])
})
var startIndex, stopIndex int
page, err := strconv.Atoi(p.ByName("page"))
if err != nil || page <= 0 {
startIndex = 0
stopIndex = fileCount
} else {
startIndex = ((page - 1) * int(pageLength))
stopIndex = (startIndex + int(pageLength))
}
if startIndex > (fileCount - 1) {
indexDump = []string{}
}
if stopIndex > fileCount {
stopIndex = fileCount
}
response, err := json.MarshalIndent(indexDump[startIndex:stopIndex], "", " ")
if err != nil {
fmt.Println(err)
serverError(w, r, nil)
return
}
w.Write(response)
if verbose {
fmt.Printf("%s | Served JSON debug page (%s) to %s in %s\n",
startTime.Format(LogDate),
humanReadableSize(len(response)),
realIP(r),
time.Since(startTime).Round(time.Microsecond),
)
}
}
}

View File

@ -35,7 +35,7 @@ func newErrorPage(title, body string) string {
func notFound(w http.ResponseWriter, r *http.Request, filePath string) error { func notFound(w http.ResponseWriter, r *http.Request, filePath string) error {
startTime := time.Now() startTime := time.Now()
if verbose { if Verbose {
fmt.Printf("%s | Unavailable file %s requested by %s\n", fmt.Printf("%s | Unavailable file %s requested by %s\n",
startTime.Format(LogDate), startTime.Format(LogDate),
filePath, filePath,
@ -57,7 +57,7 @@ func notFound(w http.ResponseWriter, r *http.Request, filePath string) error {
func serverError(w http.ResponseWriter, r *http.Request, i interface{}) { func serverError(w http.ResponseWriter, r *http.Request, i interface{}) {
startTime := time.Now() startTime := time.Now()
if verbose { if Verbose {
fmt.Printf("%s | Invalid request for %s from %s\n", fmt.Printf("%s | Invalid request for %s from %s\n",
startTime.Format(LogDate), startTime.Format(LogDate),
r.URL.Path, r.URL.Path,

View File

@ -128,7 +128,7 @@ func appendPath(directory, path string, files *Files, stats *ScanStats, register
} }
func appendPaths(path string, files *Files, filters *Filters, stats *ScanStats, types *formats.SupportedFormats) error { func appendPaths(path string, files *Files, filters *Filters, stats *ScanStats, types *formats.SupportedFormats) error {
shouldCache := cache && filters.IsEmpty() shouldCache := Cache && filters.IsEmpty()
absolutePath, err := filepath.Abs(path) absolutePath, err := filepath.Abs(path)
if err != nil { if err != nil {
@ -180,7 +180,7 @@ func appendPaths(path string, files *Files, filters *Filters, stats *ScanStats,
return nil return nil
} }
func newFile(paths []string, filters *Filters, sortOrder string, Regexes *Regexes, index *Index, registeredFormats *formats.SupportedFormats) (string, error) { func newFile(paths []string, filters *Filters, sortOrder string, Regexes *Regexes, index *FileIndex, registeredFormats *formats.SupportedFormats) (string, error) {
filePath, err := pickFile(paths, filters, sortOrder, index, registeredFormats) filePath, err := pickFile(paths, filters, sortOrder, index, registeredFormats)
if err != nil { if err != nil {
return "", nil return "", nil
@ -272,8 +272,10 @@ func splitPath(path string, Regexes *Regexes) (*Path, error) {
func tryExtensions(p *Path, registeredFormats *formats.SupportedFormats) (string, error) { func tryExtensions(p *Path, registeredFormats *formats.SupportedFormats) (string, error) {
var fileName string var fileName string
for _, extension := range registeredFormats.Extensions() { for _, format := range registeredFormats.Extensions {
fileName = fmt.Sprintf("%s%.3d%s", p.base, p.number, extension) for _, extension := range format.Extensions {
fileName = fmt.Sprintf("%s%.3d%s", p.base, p.number, extension)
}
exists, err := fileExists(fileName) exists, err := fileExists(fileName)
if err != nil { if err != nil {
@ -310,7 +312,7 @@ func pathIsValid(filePath string, paths []string) bool {
} }
switch { switch {
case verbose && !matchesPrefix: case Verbose && !matchesPrefix:
fmt.Printf("%s | Error: Failed to serve file outside specified path(s): %s\n", fmt.Printf("%s | Error: Failed to serve file outside specified path(s): %s\n",
time.Now().Format(LogDate), time.Now().Format(LogDate),
filePath, filePath,
@ -333,7 +335,7 @@ func pathHasSupportedFiles(path string, registeredFormats *formats.SupportedForm
} }
switch { switch {
case !recursive && info.IsDir() && p != path: case !Recursive && info.IsDir() && p != path:
return filepath.SkipDir return filepath.SkipDir
case !info.IsDir(): case !info.IsDir():
registered, _, _, err := formats.FileType(p, registeredFormats) registered, _, _, err := formats.FileType(p, registeredFormats)
@ -390,7 +392,7 @@ func scanPath(path string, files *Files, filters *Filters, stats *ScanStats, con
} }
switch { switch {
case !recursive && info.IsDir() && p != path: case !Recursive && info.IsDir() && p != path:
return filepath.SkipDir return filepath.SkipDir
case !info.IsDir(): case !info.IsDir():
wg.Add(1) wg.Add(1)
@ -419,7 +421,7 @@ func scanPath(path string, files *Files, filters *Filters, stats *ScanStats, con
fmt.Println(err) fmt.Println(err)
} }
if files > 0 && (files < minimumFileCount) || (files > maximumFileCount) { if files > 0 && (files < MinimumFileCount) || (files > MaximumFileCount) {
// This count will not otherwise include the parent directory itself, so increment by one // This count will not otherwise include the parent directory itself, so increment by one
stats.directoriesSkipped.Add(directories + 1) stats.directoriesSkipped.Add(directories + 1)
stats.filesSkipped.Add(files) stats.filesSkipped.Add(files)
@ -442,8 +444,8 @@ func scanPath(path string, files *Files, filters *Filters, stats *ScanStats, con
return nil return nil
} }
func fileList(paths []string, filters *Filters, sort string, index *Index, types *formats.SupportedFormats) ([]string, bool) { func fileList(paths []string, filters *Filters, sort string, index *FileIndex, types *formats.SupportedFormats) ([]string, bool) {
if cache && filters.IsEmpty() && !index.IsEmpty() { if Cache && filters.IsEmpty() && !index.IsEmpty() {
return index.Index(), true return index.Index(), true
} }
@ -496,7 +498,7 @@ func fileList(paths []string, filters *Filters, sort string, index *Index, types
return []string{}, false return []string{}, false
} }
if verbose { if Verbose {
fmt.Printf("%s | Indexed %d/%d files across %d/%d directories in %s\n", fmt.Printf("%s | Indexed %d/%d files across %d/%d directories in %s\n",
time.Now().Format(LogDate), time.Now().Format(LogDate),
stats.filesMatched.Load(), stats.filesMatched.Load(),
@ -507,7 +509,7 @@ func fileList(paths []string, filters *Filters, sort string, index *Index, types
) )
} }
if cache && filters.IsEmpty() { if Cache && filters.IsEmpty() {
index.setIndex(fileList) index.setIndex(fileList)
} }
@ -556,7 +558,7 @@ func prepareDirectories(files *Files, sort string) []string {
return directories return directories
} }
func pickFile(args []string, filters *Filters, sort string, index *Index, registeredFormats *formats.SupportedFormats) (string, error) { func pickFile(args []string, filters *Filters, sort string, index *FileIndex, registeredFormats *formats.SupportedFormats) (string, error) {
fileList, fromCache := fileList(args, filters, sort, index, registeredFormats) fileList, fromCache := fileList(args, filters, sort, index, registeredFormats)
fileCount := len(fileList) fileCount := len(fileList)

View File

@ -6,21 +6,29 @@ package cmd
import ( import (
"encoding/gob" "encoding/gob"
"encoding/json"
"fmt"
"io"
"net/http" "net/http"
"os" "os"
"sort"
"strconv"
"strings"
"sync" "sync"
"time"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"github.com/yosssi/gohtml"
"seedno.de/seednode/roulette/formats" "seedno.de/seednode/roulette/formats"
) )
type Index struct { type FileIndex struct {
mutex sync.RWMutex mutex sync.RWMutex
list []string list []string
} }
func (i *Index) Index() []string { func (i *FileIndex) Index() []string {
i.mutex.RLock() i.mutex.RLock()
val := i.list val := i.list
i.mutex.RUnlock() i.mutex.RUnlock()
@ -28,7 +36,7 @@ func (i *Index) Index() []string {
return val return val
} }
func (i *Index) Remove(path string) { func (i *FileIndex) Remove(path string) {
i.mutex.RLock() i.mutex.RLock()
tempIndex := make([]string, len(i.list)) tempIndex := make([]string, len(i.list))
copy(tempIndex, i.list) copy(tempIndex, i.list)
@ -52,25 +60,25 @@ func (i *Index) Remove(path string) {
i.mutex.Unlock() i.mutex.Unlock()
} }
func (i *Index) setIndex(val []string) { func (i *FileIndex) setIndex(val []string) {
i.mutex.Lock() i.mutex.Lock()
i.list = val i.list = val
i.mutex.Unlock() i.mutex.Unlock()
} }
func (i *Index) generateCache(args []string, registeredFormats *formats.SupportedFormats) { func (i *FileIndex) generateCache(args []string, registeredFormats *formats.SupportedFormats) {
i.mutex.Lock() i.mutex.Lock()
i.list = []string{} i.list = []string{}
i.mutex.Unlock() i.mutex.Unlock()
fileList(args, &Filters{}, "", i, registeredFormats) fileList(args, &Filters{}, "", i, registeredFormats)
if cache && cacheFile != "" { if Cache && CacheFile != "" {
i.Export(cacheFile) i.Export(CacheFile)
} }
} }
func (i *Index) IsEmpty() bool { func (i *FileIndex) IsEmpty() bool {
i.mutex.RLock() i.mutex.RLock()
length := len(i.list) length := len(i.list)
i.mutex.RUnlock() i.mutex.RUnlock()
@ -78,7 +86,7 @@ func (i *Index) IsEmpty() bool {
return length == 0 return length == 0
} }
func (i *Index) Export(path string) error { func (i *FileIndex) Export(path string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil { if err != nil {
return err return err
@ -102,7 +110,7 @@ func (i *Index) Export(path string) error {
return nil return nil
} }
func (i *Index) Import(path string) error { func (i *FileIndex) Import(path string) error {
file, err := os.OpenFile(path, os.O_RDONLY, 0600) file, err := os.OpenFile(path, os.O_RDONLY, 0600)
if err != nil { if err != nil {
return err return err
@ -130,7 +138,7 @@ func (i *Index) Import(path string) error {
return nil return nil
} }
func serveCacheClear(args []string, index *Index, registeredFormats *formats.SupportedFormats) httprouter.Handle { func serveCacheClear(args []string, index *FileIndex, registeredFormats *formats.SupportedFormats) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
index.generateCache(args, registeredFormats) index.generateCache(args, registeredFormats)
@ -139,3 +147,172 @@ func serveCacheClear(args []string, index *Index, registeredFormats *formats.Sup
w.Write([]byte("Ok")) w.Write([]byte("Ok"))
} }
} }
func serveIndexHtml(args []string, index *FileIndex, paginate bool) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "text/html")
startTime := time.Now()
indexDump := index.Index()
fileCount := len(indexDump)
var startIndex, stopIndex int
page, err := strconv.Atoi(p.ByName("page"))
if err != nil || page <= 0 {
startIndex = 0
stopIndex = fileCount
} else {
startIndex = ((page - 1) * int(PageLength))
stopIndex = (startIndex + int(PageLength))
}
if startIndex > (fileCount - 1) {
indexDump = []string{}
}
if stopIndex > fileCount {
stopIndex = fileCount
}
sort.SliceStable(indexDump, func(p, q int) bool {
return strings.ToLower(indexDump[p]) < strings.ToLower(indexDump[q])
})
var htmlBody strings.Builder
htmlBody.WriteString(`<!DOCTYPE html><html lang="en"><head>`)
htmlBody.WriteString(FaviconHtml)
htmlBody.WriteString(`<style>a{text-decoration:none;height:100%;width:100%;color:inherit;cursor:pointer}`)
htmlBody.WriteString(`table,td,tr{border:1px solid black;border-collapse:collapse}td{white-space:nowrap;padding:.5em}</style>`)
htmlBody.WriteString(fmt.Sprintf("<title>Index contains %d files</title></head><body><table>", fileCount))
if len(indexDump) > 0 {
for _, v := range indexDump[startIndex:stopIndex] {
var shouldSort = ""
if Sorting {
shouldSort = "?sort=asc"
}
htmlBody.WriteString(fmt.Sprintf("<tr><td><a href=\"%s%s%s\">%s</a></td></tr>\n", MediaPrefix, v, shouldSort, v))
}
}
if PageLength != 0 {
var firstPage int = 1
var lastPage int
if fileCount%int(PageLength) == 0 {
lastPage = fileCount / int(PageLength)
} else {
lastPage = (fileCount / int(PageLength)) + 1
}
if paginate {
var prevStatus, nextStatus string = "", ""
if page <= 1 {
prevStatus = " disabled"
}
if page >= lastPage {
nextStatus = " disabled"
}
prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := page + 1
if nextPage > lastPage {
nextPage = fileCount / int(PageLength)
}
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\">First</button>",
firstPage))
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\"%s>Prev</button>",
prevPage,
prevStatus))
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\"%s>Next</button>",
nextPage,
nextStatus))
htmlBody.WriteString(fmt.Sprintf("<button onclick=\"window.location.href = '/html/%d';\">Last</button>",
lastPage))
}
}
htmlBody.WriteString(`</table></body></html>`)
b, err := io.WriteString(w, gohtml.Format(htmlBody.String()))
if err != nil {
return
}
if Verbose {
fmt.Printf("%s | Served HTML index page (%s) to %s in %s\n",
startTime.Format(LogDate),
humanReadableSize(b),
realIP(r),
time.Since(startTime).Round(time.Microsecond),
)
}
}
}
func serveIndexJson(args []string, index *FileIndex) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
startTime := time.Now()
indexDump := index.Index()
fileCount := len(indexDump)
sort.SliceStable(indexDump, func(p, q int) bool {
return strings.ToLower(indexDump[p]) < strings.ToLower(indexDump[q])
})
var startIndex, stopIndex int
page, err := strconv.Atoi(p.ByName("page"))
if err != nil || page <= 0 {
startIndex = 0
stopIndex = fileCount
} else {
startIndex = ((page - 1) * int(PageLength))
stopIndex = (startIndex + int(PageLength))
}
if startIndex > (fileCount - 1) {
indexDump = []string{}
}
if stopIndex > fileCount {
stopIndex = fileCount
}
response, err := json.MarshalIndent(indexDump[startIndex:stopIndex], "", " ")
if err != nil {
fmt.Println(err)
serverError(w, r, nil)
return
}
w.Write(response)
if Verbose {
fmt.Printf("%s | Served JSON index page (%s) to %s in %s\n",
startTime.Format(LogDate),
humanReadableSize(len(response)),
realIP(r),
time.Since(startTime).Round(time.Microsecond),
)
}
}
}

View File

@ -12,45 +12,52 @@ import (
) )
const ( const (
Version string = "0.69.4" ReleaseVersion string = "0.70.3"
) )
var ( var (
all bool All bool
audio bool Audio bool
bind string Bind string
cache bool Cache bool
cacheFile string CacheFile string
debug bool Filtering bool
filtering bool Flash bool
images bool Images bool
maximumFileCount uint64 Index bool
minimumFileCount uint64 MaximumFileCount uint64
pageLength uint64 MinimumFileCount uint64
port uint16 PageLength uint64
profile bool Port uint16
recursive bool Profile bool
refreshInterval string Recursive bool
russian bool RefreshInterval string
sorting bool Russian bool
statistics bool Sorting bool
statisticsFile string Statistics bool
text bool StatisticsFile string
verbose bool Text bool
version bool Verbose bool
videos bool Version bool
Videos bool
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "roulette <path> [path]...", Use: "roulette <path> [path]...",
Short: "Serves random media from the specified directories.", Short: "Serves random media from the specified directories.",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
PreRun: func(cmd *cobra.Command, args []string) { PreRun: func(cmd *cobra.Command, args []string) {
if debug { // enable image support if no other flags are passed, to retain backwards compatibility
// to be replaced with MarkFlagsOneRequired on next spf13/cobra update
if !(All || Audio || Flash || Images || Text || Videos) {
Images = true
}
if Index {
cmd.MarkFlagRequired("cache") cmd.MarkFlagRequired("cache")
} }
if refreshInterval != "" { if RefreshInterval != "" {
interval, err := time.ParseDuration(refreshInterval) interval, err := time.ParseDuration(RefreshInterval)
if err != nil || interval < 500*time.Millisecond { if err != nil || interval < 500*time.Millisecond {
log.Fatal(ErrIncorrectRefreshInterval) log.Fatal(ErrIncorrectRefreshInterval)
} }
@ -75,29 +82,30 @@ func Execute() {
} }
func init() { func init() {
rootCmd.Flags().BoolVar(&all, "all", false, "enable all supported file types") rootCmd.Flags().BoolVar(&All, "all", false, "enable all supported file types")
rootCmd.Flags().BoolVar(&audio, "audio", false, "enable support for audio files") rootCmd.Flags().BoolVar(&Audio, "audio", false, "enable support for audio files")
rootCmd.Flags().StringVarP(&bind, "bind", "b", "0.0.0.0", "address to bind to") rootCmd.Flags().StringVarP(&Bind, "bind", "b", "0.0.0.0", "address to bind to")
rootCmd.Flags().BoolVarP(&cache, "cache", "c", false, "generate directory cache at startup") rootCmd.Flags().BoolVarP(&Cache, "cache", "c", false, "generate directory cache at startup")
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(&debug, "debug", "d", false, "expose debug endpoint") 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(&images, "images", true, "enable support for image files") rootCmd.Flags().BoolVar(&Images, "images", false, "enable support for image files")
rootCmd.Flags().Uint64Var(&maximumFileCount, "maximum-files", 1<<64-1, "skip directories with file counts above this value") rootCmd.Flags().BoolVarP(&Index, "index", "i", false, "expose index endpoints")
rootCmd.Flags().Uint64Var(&minimumFileCount, "minimum-files", 1, "skip directories with file counts below this value") rootCmd.Flags().Uint64Var(&MaximumFileCount, "maximum-files", 1<<64-1, "skip directories with file counts above this value")
rootCmd.Flags().Uint64Var(&pageLength, "page-length", 0, "pagination length for statistics and debug pages") rootCmd.Flags().Uint64Var(&MinimumFileCount, "minimum-files", 1, "skip directories with file counts below this value")
rootCmd.Flags().Uint16VarP(&port, "port", "p", 8080, "port to listen on") rootCmd.Flags().Uint64Var(&PageLength, "page-length", 0, "pagination length for statistics and debug pages")
rootCmd.Flags().BoolVar(&profile, "profile", false, "register net/http/pprof handlers") rootCmd.Flags().Uint16VarP(&Port, "port", "p", 8080, "port to listen on")
rootCmd.Flags().BoolVarP(&recursive, "recursive", "r", false, "recurse into subdirectories") rootCmd.Flags().BoolVar(&Profile, "profile", false, "register net/http/pprof handlers")
rootCmd.Flags().StringVar(&refreshInterval, "refresh-interval", "", "force refresh interval equal to this duration (minimum 500ms)") rootCmd.Flags().BoolVarP(&Recursive, "recursive", "r", false, "recurse into subdirectories")
rootCmd.Flags().BoolVar(&russian, "russian", false, "remove selected images after serving") rootCmd.Flags().StringVar(&RefreshInterval, "refresh-interval", "", "force refresh interval equal to this duration (minimum 500ms)")
rootCmd.Flags().BoolVarP(&sorting, "sort", "s", false, "enable sorting") rootCmd.Flags().BoolVar(&Russian, "russian", false, "remove selected images after serving")
rootCmd.Flags().BoolVar(&statistics, "stats", false, "expose stats endpoint") rootCmd.Flags().BoolVarP(&Sorting, "sort", "s", false, "enable sorting")
rootCmd.Flags().StringVar(&statisticsFile, "stats-file", "", "path to optional persistent stats file") rootCmd.Flags().BoolVar(&Statistics, "stats", false, "expose stats endpoint")
rootCmd.Flags().BoolVar(&text, "text", false, "enable support for text files") rootCmd.Flags().StringVar(&StatisticsFile, "stats-file", "", "path to optional persistent stats file")
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "log accessed files to stdout") rootCmd.Flags().BoolVar(&Text, "text", false, "enable support for text files")
rootCmd.Flags().BoolVarP(&version, "version", "V", false, "display version and exit") rootCmd.Flags().BoolVarP(&Verbose, "verbose", "v", false, "log accessed files to stdout")
rootCmd.Flags().BoolVar(&videos, "video", false, "enable support for video files") rootCmd.Flags().BoolVarP(&Version, "version", "V", false, "display version and exit")
rootCmd.Flags().BoolVar(&Videos, "video", false, "enable support for video files")
rootCmd.Flags().SetInterspersed(true) rootCmd.Flags().SetInterspersed(true)
@ -109,5 +117,5 @@ func init() {
}) })
rootCmd.SetVersionTemplate("roulette v{{.Version}}\n") rootCmd.SetVersionTemplate("roulette v{{.Version}}\n")
rootCmd.Version = Version rootCmd.Version = ReleaseVersion
} }

View File

@ -118,8 +118,8 @@ func (s *ServeStats) ListFiles(page int) ([]byte, error) {
startIndex = 0 startIndex = 0
stopIndex = len(stats.List) - 1 stopIndex = len(stats.List) - 1
} else { } else {
startIndex = ((page - 1) * int(pageLength)) startIndex = ((page - 1) * int(PageLength))
stopIndex = (startIndex + int(pageLength)) stopIndex = (startIndex + int(PageLength))
} }
if startIndex > len(stats.List)-1 { if startIndex > len(stats.List)-1 {
@ -231,7 +231,7 @@ func serveStats(args []string, stats *ServeStats) httprouter.Handle {
w.Write(response) w.Write(response)
if verbose { if Verbose {
fmt.Printf("%s | Served statistics page (%s) to %s in %s\n", fmt.Printf("%s | Served statistics page (%s) to %s in %s\n",
startTime.Format(LogDate), startTime.Format(LogDate),
humanReadableSize(len(response)), humanReadableSize(len(response)),
@ -240,8 +240,8 @@ func serveStats(args []string, stats *ServeStats) httprouter.Handle {
) )
} }
if statisticsFile != "" { if StatisticsFile != "" {
stats.Export(statisticsFile) stats.Export(StatisticsFile)
} }
} }
} }

View File

@ -13,13 +13,13 @@ import (
"time" "time"
) )
func RefreshInterval(r *http.Request) (int64, string) { func refreshInterval(r *http.Request) (int64, string) {
var interval string var interval string
if refreshInterval == "" { if RefreshInterval == "" {
interval = r.URL.Query().Get("refresh") interval = r.URL.Query().Get("refresh")
} else { } else {
interval = refreshInterval interval = RefreshInterval
} }
duration, err := time.ParseDuration(interval) duration, err := time.ParseDuration(interval)
@ -68,7 +68,7 @@ func generateQueryParams(filters *Filters, sortOrder, refreshInterval string) st
queryParams.WriteString("?") queryParams.WriteString("?")
if filtering { if Filtering {
queryParams.WriteString("include=") queryParams.WriteString("include=")
if filters.HasIncludes() { if filters.HasIncludes() {
queryParams.WriteString(filters.Includes()) queryParams.WriteString(filters.Includes())
@ -82,7 +82,7 @@ func generateQueryParams(filters *Filters, sortOrder, refreshInterval string) st
hasParams = true hasParams = true
} }
if sorting { if Sorting {
if hasParams { if hasParams {
queryParams.WriteString("&") queryParams.WriteString("&")
} }

View File

@ -37,7 +37,7 @@ const (
Timeout time.Duration = 10 * time.Second Timeout time.Duration = 10 * time.Second
) )
func serveStaticFile(paths []string, stats *ServeStats, index *Index) httprouter.Handle { func serveStaticFile(paths []string, stats *ServeStats, index *FileIndex) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
path := strings.TrimPrefix(r.URL.Path, SourcePrefix) path := strings.TrimPrefix(r.URL.Path, SourcePrefix)
@ -95,7 +95,7 @@ func serveStaticFile(paths []string, stats *ServeStats, index *Index) httprouter
fileSize := humanReadableSize(len(buf)) fileSize := humanReadableSize(len(buf))
if russian { if Russian {
err = os.Remove(filePath) err = os.Remove(filePath)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
@ -105,12 +105,12 @@ func serveStaticFile(paths []string, stats *ServeStats, index *Index) httprouter
return return
} }
if cache { if Cache {
index.Remove(filePath) index.Remove(filePath)
} }
} }
if verbose { if Verbose {
fmt.Printf("%s | Served %s (%s) to %s in %s\n", fmt.Printf("%s | Served %s (%s) to %s in %s\n",
startTime.Format(LogDate), startTime.Format(LogDate),
filePath, filePath,
@ -120,14 +120,14 @@ func serveStaticFile(paths []string, stats *ServeStats, index *Index) httprouter
) )
} }
if statistics { if Statistics {
stats.incrementCounter(filePath, startTime, fileSize) stats.incrementCounter(filePath, startTime, fileSize)
} }
} }
} }
func serveRoot(paths []string, Regexes *Regexes, index *Index, registeredFormats *formats.SupportedFormats) httprouter.Handle { func serveRoot(paths []string, Regexes *Regexes, index *FileIndex, registeredFormats *formats.SupportedFormats) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
refererUri, err := stripQueryParams(refererToUri(r.Referer())) refererUri, err := stripQueryParams(refererToUri(r.Referer()))
if err != nil { if err != nil {
@ -147,7 +147,7 @@ func serveRoot(paths []string, Regexes *Regexes, index *Index, registeredFormats
sortOrder := SortOrder(r) sortOrder := SortOrder(r)
_, refreshInterval := RefreshInterval(r) _, refreshInterval := refreshInterval(r)
var filePath string var filePath string
@ -200,7 +200,7 @@ func serveRoot(paths []string, Regexes *Regexes, index *Index, registeredFormats
} }
} }
func serveMedia(paths []string, Regexes *Regexes, index *Index, registeredFormats *formats.SupportedFormats) httprouter.Handle { func serveMedia(paths []string, Regexes *Regexes, index *FileIndex, registeredFormats *formats.SupportedFormats) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
filters := &Filters{ filters := &Filters{
includes: splitQueryParams(r.URL.Query().Get("include"), Regexes), includes: splitQueryParams(r.URL.Query().Get("include"), Regexes),
@ -250,7 +250,7 @@ func serveMedia(paths []string, Regexes *Regexes, index *Index, registeredFormat
w.Header().Add("Content-Type", "text/html") w.Header().Add("Content-Type", "text/html")
refreshTimer, refreshInterval := RefreshInterval(r) refreshTimer, refreshInterval := refreshInterval(r)
queryParams := generateQueryParams(filters, sortOrder, refreshInterval) queryParams := generateQueryParams(filters, sortOrder, refreshInterval)
@ -286,7 +286,7 @@ func serveMedia(paths []string, Regexes *Regexes, index *Index, registeredFormat
func serveVersion() httprouter.Handle { func serveVersion() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
data := []byte(fmt.Sprintf("roulette v%s\n", Version)) data := []byte(fmt.Sprintf("roulette v%s\n", ReleaseVersion))
w.Header().Write(bytes.NewBufferString("Content-Length: " + strconv.Itoa(len(data)))) w.Header().Write(bytes.NewBufferString("Content-Length: " + strconv.Itoa(len(data))))
@ -304,7 +304,7 @@ func ServePage(args []string) error {
} }
} }
bindHost, err := net.LookupHost(bind) bindHost, err := net.LookupHost(Bind)
if err != nil { if err != nil {
return err return err
} }
@ -314,21 +314,30 @@ func ServePage(args []string) error {
return errors.New("invalid bind address provided") return errors.New("invalid bind address provided")
} }
registeredFormats := &formats.SupportedFormats{} mux := httprouter.New()
if audio || all { registeredFormats := &formats.SupportedFormats{
Extensions: make(map[string]*formats.SupportedFormat),
MimeTypes: make(map[string]*formats.SupportedFormat),
}
if Audio || All {
registeredFormats.Add(formats.RegisterAudioFormats()) registeredFormats.Add(formats.RegisterAudioFormats())
} }
if images || all { if Flash || All {
registeredFormats.Add(formats.RegisterFlashFormats())
}
if Images || All {
registeredFormats.Add(formats.RegisterImageFormats()) registeredFormats.Add(formats.RegisterImageFormats())
} }
if text || all { if Text || All {
registeredFormats.Add(formats.RegisterTextFormats()) registeredFormats.Add(formats.RegisterTextFormats())
} }
if videos || all { if Videos || All {
registeredFormats.Add(formats.RegisterVideoFormats()) registeredFormats.Add(formats.RegisterVideoFormats())
} }
@ -341,13 +350,11 @@ func ServePage(args []string) error {
return ErrNoMediaFound return ErrNoMediaFound
} }
if russian { if Russian {
fmt.Printf("WARNING! Files *will* be deleted after serving!\n\n") fmt.Printf("WARNING! Files *will* be deleted after serving!\n\n")
} }
mux := httprouter.New() index := &FileIndex{
index := &Index{
mutex: sync.RWMutex{}, mutex: sync.RWMutex{},
list: []string{}, list: []string{},
} }
@ -358,7 +365,7 @@ func ServePage(args []string) error {
} }
srv := &http.Server{ srv := &http.Server{
Addr: net.JoinHostPort(bind, strconv.Itoa(int(port))), Addr: net.JoinHostPort(Bind, strconv.Itoa(int(Port))),
Handler: mux, Handler: mux,
IdleTimeout: 10 * time.Minute, IdleTimeout: 10 * time.Minute,
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
@ -387,11 +394,11 @@ func ServePage(args []string) error {
mux.GET("/version", serveVersion()) mux.GET("/version", serveVersion())
if cache { if Cache {
skipIndex := false skipIndex := false
if cacheFile != "" { if CacheFile != "" {
err := index.Import(cacheFile) err := index.Import(CacheFile)
if err == nil { if err == nil {
skipIndex = true skipIndex = true
} }
@ -404,19 +411,19 @@ func ServePage(args []string) error {
mux.GET("/clear_cache", serveCacheClear(args, index, registeredFormats)) mux.GET("/clear_cache", serveCacheClear(args, index, registeredFormats))
} }
if debug { if Index {
mux.GET("/html/", serveDebugHtml(args, index, false)) mux.GET("/html/", serveIndexHtml(args, index, false))
if pageLength != 0 { if PageLength != 0 {
mux.GET("/html/:page", serveDebugHtml(args, index, true)) mux.GET("/html/:page", serveIndexHtml(args, index, true))
} }
mux.GET("/json", serveDebugJson(args, index)) mux.GET("/json", serveIndexJson(args, index))
if pageLength != 0 { if PageLength != 0 {
mux.GET("/json/:page", serveDebugJson(args, index)) mux.GET("/json/:page", serveIndexJson(args, index))
} }
} }
if profile { if Profile {
mux.HandlerFunc("GET", "/debug/pprof/", pprof.Index) mux.HandlerFunc("GET", "/debug/pprof/", pprof.Index)
mux.HandlerFunc("GET", "/debug/pprof/cmdline", pprof.Cmdline) mux.HandlerFunc("GET", "/debug/pprof/cmdline", pprof.Cmdline)
mux.HandlerFunc("GET", "/debug/pprof/profile", pprof.Profile) mux.HandlerFunc("GET", "/debug/pprof/profile", pprof.Profile)
@ -424,9 +431,9 @@ func ServePage(args []string) error {
mux.HandlerFunc("GET", "/debug/pprof/trace", pprof.Trace) mux.HandlerFunc("GET", "/debug/pprof/trace", pprof.Trace)
} }
if statistics { if Statistics {
if statisticsFile != "" { if StatisticsFile != "" {
stats.Import(statisticsFile) stats.Import(StatisticsFile)
gracefulShutdown := make(chan os.Signal, 1) gracefulShutdown := make(chan os.Signal, 1)
signal.Notify(gracefulShutdown, syscall.SIGINT, syscall.SIGTERM) signal.Notify(gracefulShutdown, syscall.SIGINT, syscall.SIGTERM)
@ -434,14 +441,14 @@ func ServePage(args []string) error {
go func() { go func() {
<-gracefulShutdown <-gracefulShutdown
stats.Export(statisticsFile) stats.Export(StatisticsFile)
os.Exit(0) os.Exit(0)
}() }()
} }
mux.GET("/stats", serveStats(args, stats)) mux.GET("/stats", serveStats(args, stats))
if pageLength != 0 { if PageLength != 0 {
mux.GET("/stats/:page", serveStats(args, stats)) mux.GET("/stats/:page", serveStats(args, stats))
} }
} }

View File

@ -10,7 +10,7 @@ import (
func RegisterAudioFormats() *SupportedFormat { func RegisterAudioFormats() *SupportedFormat {
return &SupportedFormat{ return &SupportedFormat{
Css: ``, Css: ``,
Title: func(queryParams, fileUri, filePath, fileName, mime string) string { Title: func(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<title>%s</title>`, fileName) return fmt.Sprintf(`<title>%s</title>`, fileName)
}, },

36
formats/flash.go Normal file
View File

@ -0,0 +1,36 @@
/*
Copyright © 2023 Seednode <seednode@seedno.de>
*/
package formats
import (
"fmt"
"strings"
)
func RegisterFlashFormats() *SupportedFormat {
return &SupportedFormat{
Css: ``,
Title: func(queryParams, fileUri, filePath, fileName, mime string) string {
return fmt.Sprintf(`<title>%s</title>`, fileName)
},
Body: func(queryParams, fileUri, filePath, fileName, mime string) string {
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(`<br /><button onclick=\"window.location.href = '/%s';\">Next</button>`, queryParams))
return html.String()
},
Extensions: []string{
`.swf`,
},
MimeTypes: []string{
`application/x-shockwave-flash`,
},
Validate: func(filePath string) bool {
return true
},
}
}

View File

@ -24,7 +24,7 @@ type Dimensions struct {
func RegisterImageFormats() *SupportedFormat { func RegisterImageFormats() *SupportedFormat {
return &SupportedFormat{ return &SupportedFormat{
Css: ``, Css: ``,
Title: func(queryParams, fileUri, filePath, fileName, mime string) string { Title: func(queryParams, fileUri, filePath, fileName, mime string) string {
dimensions, err := ImageDimensions(filePath) dimensions, err := ImageDimensions(filePath)
if err != nil { if err != nil {

View File

@ -39,7 +39,6 @@ func RegisterTextFormats() *SupportedFormat {
}, },
MimeTypes: []string{ MimeTypes: []string{
`application/json`, `application/json`,
`application/octet-stream`,
`application/xml`, `application/xml`,
`text/css`, `text/css`,
`text/csv`, `text/csv`,

View File

@ -8,6 +8,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"os" "os"
"path/filepath"
) )
type SupportedFormat struct { type SupportedFormat struct {
@ -20,46 +21,27 @@ type SupportedFormat struct {
} }
type SupportedFormats struct { type SupportedFormats struct {
types []*SupportedFormat Extensions map[string]*SupportedFormat
MimeTypes map[string]*SupportedFormat
} }
func (s *SupportedFormats) Add(t *SupportedFormat) { func (s *SupportedFormats) Add(t *SupportedFormat) {
s.types = append(s.types, t) for _, v := range t.Extensions {
} _, exists := s.Extensions[v]
if !exists {
func (s *SupportedFormats) Extensions() []string { s.Extensions[v] = t
var extensions []string
for _, t := range s.types {
extensions = append(extensions, t.Extensions...)
}
return extensions
}
func (s *SupportedFormats) MimeTypes() []string {
var mimeTypes []string
for _, t := range s.types {
mimeTypes = append(mimeTypes, t.MimeTypes...)
}
return mimeTypes
}
func (s *SupportedFormats) Type(mimeType string) *SupportedFormat {
for i := range s.types {
for _, m := range s.types[i].MimeTypes {
if mimeType == m {
return s.types[i]
}
} }
} }
return nil for _, v := range t.MimeTypes {
_, exists := s.Extensions[v]
if !exists {
s.MimeTypes[v] = t
}
}
} }
func FileType(path string, types *SupportedFormats) (bool, *SupportedFormat, string, error) { func FileType(path string, registeredFormats *SupportedFormats) (bool, *SupportedFormat, string, error) {
file, err := os.Open(path) file, err := os.Open(path)
switch { switch {
case errors.Is(err, os.ErrNotExist): case errors.Is(err, os.ErrNotExist):
@ -74,12 +56,16 @@ func FileType(path string, types *SupportedFormats) (bool, *SupportedFormat, str
mimeType := http.DetectContentType(head) mimeType := http.DetectContentType(head)
for _, v := range types.MimeTypes() { // try identifying files by mime types first
if mimeType == v { fileType, exists := registeredFormats.MimeTypes[mimeType]
fileType := types.Type(mimeType) if exists {
return fileType.Validate(path), fileType, mimeType, nil
}
return fileType.Validate(path), fileType, mimeType, nil // if mime type detection fails, use the file extension
} fileType, exists = registeredFormats.Extensions[filepath.Ext(path)]
if exists {
return fileType.Validate(path), fileType, mimeType, nil
} }
return false, nil, "", nil return false, nil, "", nil