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 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
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.
## 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
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")
-c, --cache generate directory cache at startup
--cache-file string path to optional persistent cache file
-d, --debug expose debug endpoint
-f, --filter enable filtering
--flash enable support for shockwave flash files (via ruffle.rs) (default true)
-h, --help help for roulette
--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)
--minimum-files uint skip directories with file counts below this value (default 1)
--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 {
startTime := time.Now()
if verbose {
if Verbose {
fmt.Printf("%s | Unavailable file %s requested by %s\n",
startTime.Format(LogDate),
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{}) {
startTime := time.Now()
if verbose {
if Verbose {
fmt.Printf("%s | Invalid request for %s from %s\n",
startTime.Format(LogDate),
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 {
shouldCache := cache && filters.IsEmpty()
shouldCache := Cache && filters.IsEmpty()
absolutePath, err := filepath.Abs(path)
if err != nil {
@ -180,7 +180,7 @@ func appendPaths(path string, files *Files, filters *Filters, stats *ScanStats,
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)
if err != nil {
return "", nil
@ -272,8 +272,10 @@ func splitPath(path string, Regexes *Regexes) (*Path, error) {
func tryExtensions(p *Path, registeredFormats *formats.SupportedFormats) (string, error) {
var fileName string
for _, extension := range registeredFormats.Extensions() {
fileName = fmt.Sprintf("%s%.3d%s", p.base, p.number, extension)
for _, format := range registeredFormats.Extensions {
for _, extension := range format.Extensions {
fileName = fmt.Sprintf("%s%.3d%s", p.base, p.number, extension)
}
exists, err := fileExists(fileName)
if err != nil {
@ -310,7 +312,7 @@ func pathIsValid(filePath string, paths []string) bool {
}
switch {
case verbose && !matchesPrefix:
case Verbose && !matchesPrefix:
fmt.Printf("%s | Error: Failed to serve file outside specified path(s): %s\n",
time.Now().Format(LogDate),
filePath,
@ -333,7 +335,7 @@ func pathHasSupportedFiles(path string, registeredFormats *formats.SupportedForm
}
switch {
case !recursive && info.IsDir() && p != path:
case !Recursive && info.IsDir() && p != path:
return filepath.SkipDir
case !info.IsDir():
registered, _, _, err := formats.FileType(p, registeredFormats)
@ -390,7 +392,7 @@ func scanPath(path string, files *Files, filters *Filters, stats *ScanStats, con
}
switch {
case !recursive && info.IsDir() && p != path:
case !Recursive && info.IsDir() && p != path:
return filepath.SkipDir
case !info.IsDir():
wg.Add(1)
@ -419,7 +421,7 @@ func scanPath(path string, files *Files, filters *Filters, stats *ScanStats, con
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
stats.directoriesSkipped.Add(directories + 1)
stats.filesSkipped.Add(files)
@ -442,8 +444,8 @@ func scanPath(path string, files *Files, filters *Filters, stats *ScanStats, con
return nil
}
func fileList(paths []string, filters *Filters, sort string, index *Index, types *formats.SupportedFormats) ([]string, bool) {
if cache && filters.IsEmpty() && !index.IsEmpty() {
func fileList(paths []string, filters *Filters, sort string, index *FileIndex, types *formats.SupportedFormats) ([]string, bool) {
if Cache && filters.IsEmpty() && !index.IsEmpty() {
return index.Index(), true
}
@ -496,7 +498,7 @@ func fileList(paths []string, filters *Filters, sort string, index *Index, types
return []string{}, false
}
if verbose {
if Verbose {
fmt.Printf("%s | Indexed %d/%d files across %d/%d directories in %s\n",
time.Now().Format(LogDate),
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)
}
@ -556,7 +558,7 @@ func prepareDirectories(files *Files, sort string) []string {
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)
fileCount := len(fileList)

View File

@ -6,21 +6,29 @@ package cmd
import (
"encoding/gob"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/julienschmidt/httprouter"
"github.com/klauspost/compress/zstd"
"github.com/yosssi/gohtml"
"seedno.de/seednode/roulette/formats"
)
type Index struct {
type FileIndex struct {
mutex sync.RWMutex
list []string
}
func (i *Index) Index() []string {
func (i *FileIndex) Index() []string {
i.mutex.RLock()
val := i.list
i.mutex.RUnlock()
@ -28,7 +36,7 @@ func (i *Index) Index() []string {
return val
}
func (i *Index) Remove(path string) {
func (i *FileIndex) Remove(path string) {
i.mutex.RLock()
tempIndex := make([]string, len(i.list))
copy(tempIndex, i.list)
@ -52,25 +60,25 @@ func (i *Index) Remove(path string) {
i.mutex.Unlock()
}
func (i *Index) setIndex(val []string) {
func (i *FileIndex) setIndex(val []string) {
i.mutex.Lock()
i.list = val
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.list = []string{}
i.mutex.Unlock()
fileList(args, &Filters{}, "", i, registeredFormats)
if cache && cacheFile != "" {
i.Export(cacheFile)
if Cache && CacheFile != "" {
i.Export(CacheFile)
}
}
func (i *Index) IsEmpty() bool {
func (i *FileIndex) IsEmpty() bool {
i.mutex.RLock()
length := len(i.list)
i.mutex.RUnlock()
@ -78,7 +86,7 @@ func (i *Index) IsEmpty() bool {
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)
if err != nil {
return err
@ -102,7 +110,7 @@ func (i *Index) Export(path string) error {
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)
if err != nil {
return err
@ -130,7 +138,7 @@ func (i *Index) Import(path string) error {
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) {
index.generateCache(args, registeredFormats)
@ -139,3 +147,172 @@ func serveCacheClear(args []string, index *Index, registeredFormats *formats.Sup
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 (
Version string = "0.69.4"
ReleaseVersion string = "0.70.3"
)
var (
all bool
audio bool
bind string
cache bool
cacheFile string
debug bool
filtering bool
images bool
maximumFileCount uint64
minimumFileCount uint64
pageLength uint64
port uint16
profile bool
recursive bool
refreshInterval string
russian bool
sorting bool
statistics bool
statisticsFile string
text bool
verbose bool
version bool
videos bool
All bool
Audio bool
Bind string
Cache bool
CacheFile string
Filtering bool
Flash bool
Images bool
Index bool
MaximumFileCount uint64
MinimumFileCount uint64
PageLength uint64
Port uint16
Profile bool
Recursive bool
RefreshInterval string
Russian bool
Sorting bool
Statistics bool
StatisticsFile string
Text bool
Verbose bool
Version bool
Videos bool
rootCmd = &cobra.Command{
Use: "roulette <path> [path]...",
Short: "Serves random media from the specified directories.",
Args: cobra.MinimumNArgs(1),
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")
}
if refreshInterval != "" {
interval, err := time.ParseDuration(refreshInterval)
if RefreshInterval != "" {
interval, err := time.ParseDuration(RefreshInterval)
if err != nil || interval < 500*time.Millisecond {
log.Fatal(ErrIncorrectRefreshInterval)
}
@ -75,29 +82,30 @@ func Execute() {
}
func init() {
rootCmd.Flags().BoolVar(&all, "all", false, "enable all supported file types")
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().BoolVarP(&cache, "cache", "c", false, "generate directory cache at startup")
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().BoolVar(&images, "images", true, "enable support for image files")
rootCmd.Flags().Uint64Var(&maximumFileCount, "maximum-files", 1<<64-1, "skip directories with file counts above this value")
rootCmd.Flags().Uint64Var(&minimumFileCount, "minimum-files", 1, "skip directories with file counts below this value")
rootCmd.Flags().Uint64Var(&pageLength, "page-length", 0, "pagination length for statistics and debug pages")
rootCmd.Flags().Uint16VarP(&port, "port", "p", 8080, "port to listen on")
rootCmd.Flags().BoolVar(&profile, "profile", false, "register net/http/pprof handlers")
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().BoolVar(&russian, "russian", false, "remove selected images after serving")
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().BoolVarP(&verbose, "verbose", "v", false, "log accessed files to stdout")
rootCmd.Flags().BoolVarP(&version, "version", "V", false, "display version and exit")
rootCmd.Flags().BoolVar(&videos, "video", false, "enable support for video files")
rootCmd.Flags().BoolVar(&All, "all", false, "enable all supported file types")
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().BoolVarP(&Cache, "cache", "c", false, "generate directory cache at startup")
rootCmd.Flags().StringVar(&CacheFile, "cache-file", "", "path to optional persistent cache file")
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", false, "enable support for image files")
rootCmd.Flags().BoolVarP(&Index, "index", "i", false, "expose index endpoints")
rootCmd.Flags().Uint64Var(&MaximumFileCount, "maximum-files", 1<<64-1, "skip directories with file counts above this value")
rootCmd.Flags().Uint64Var(&MinimumFileCount, "minimum-files", 1, "skip directories with file counts below this value")
rootCmd.Flags().Uint64Var(&PageLength, "page-length", 0, "pagination length for statistics and debug pages")
rootCmd.Flags().Uint16VarP(&Port, "port", "p", 8080, "port to listen on")
rootCmd.Flags().BoolVar(&Profile, "profile", false, "register net/http/pprof handlers")
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().BoolVar(&Russian, "russian", false, "remove selected images after serving")
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().BoolVarP(&Verbose, "verbose", "v", false, "log accessed files to stdout")
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)
@ -109,5 +117,5 @@ func init() {
})
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
stopIndex = len(stats.List) - 1
} else {
startIndex = ((page - 1) * int(pageLength))
stopIndex = (startIndex + int(pageLength))
startIndex = ((page - 1) * int(PageLength))
stopIndex = (startIndex + int(PageLength))
}
if startIndex > len(stats.List)-1 {
@ -231,7 +231,7 @@ func serveStats(args []string, stats *ServeStats) httprouter.Handle {
w.Write(response)
if verbose {
if Verbose {
fmt.Printf("%s | Served statistics page (%s) to %s in %s\n",
startTime.Format(LogDate),
humanReadableSize(len(response)),
@ -240,8 +240,8 @@ func serveStats(args []string, stats *ServeStats) httprouter.Handle {
)
}
if statisticsFile != "" {
stats.Export(statisticsFile)
if StatisticsFile != "" {
stats.Export(StatisticsFile)
}
}
}

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import (
func RegisterAudioFormats() *SupportedFormat {
return &SupportedFormat{
Css: ``,
Css: ``,
Title: func(queryParams, fileUri, filePath, fileName, mime string) string {
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 {
return &SupportedFormat{
Css: ``,
Css: ``,
Title: func(queryParams, fileUri, filePath, fileName, mime string) string {
dimensions, err := ImageDimensions(filePath)
if err != nil {

View File

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

View File

@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"os"
"path/filepath"
)
type SupportedFormat struct {
@ -20,46 +21,27 @@ type SupportedFormat struct {
}
type SupportedFormats struct {
types []*SupportedFormat
Extensions map[string]*SupportedFormat
MimeTypes map[string]*SupportedFormat
}
func (s *SupportedFormats) Add(t *SupportedFormat) {
s.types = append(s.types, t)
}
func (s *SupportedFormats) Extensions() []string {
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]
}
for _, v := range t.Extensions {
_, exists := s.Extensions[v]
if !exists {
s.Extensions[v] = t
}
}
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)
switch {
case errors.Is(err, os.ErrNotExist):
@ -74,12 +56,16 @@ func FileType(path string, types *SupportedFormats) (bool, *SupportedFormat, str
mimeType := http.DetectContentType(head)
for _, v := range types.MimeTypes() {
if mimeType == v {
fileType := types.Type(mimeType)
// try identifying files by mime types first
fileType, exists := registeredFormats.MimeTypes[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