Skip to content

Instantly share code, notes, and snippets.

@The0x539
Created December 14, 2017 23:14
Show Gist options
  • Save The0x539/82e034468c4489edf37b28bd4f4849b9 to your computer and use it in GitHub Desktop.
Save The0x539/82e034468c4489edf37b28bd4f4849b9 to your computer and use it in GitHub Desktop.
CADDY BROWSE VIDEO THINGY
// Copyright 2015 Light Code Labs, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package browse provides middleware for listing files in a directory
// when directory path is requested instead of a specific file.
package browse
import (
"bytes"
"encoding/json"
"net/http"
"net/url"
"os"
"path"
"regexp"
"sort"
"strings"
"text/template"
"time"
"github.com/dustin/go-humanize"
"github.com/mholt/caddy/caddyhttp/httpserver"
"github.com/mholt/caddy/caddyhttp/staticfiles"
)
const (
sortByName = "name"
sortByNameDirFirst = "namedirfirst"
sortBySize = "size"
sortByTime = "time"
)
// Browse is an http.Handler that can show a file listing when
// directories in the given paths are specified.
type Browse struct {
Next httpserver.Handler
Configs []Config
IgnoreIndexes bool
}
// Config is a configuration for browsing in a particular path.
type Config struct {
PathScope string // the base path the URL must match to enable browsing
Fs staticfiles.FileServer
Variables interface{}
Template *template.Template
}
// A Listing is the context used to fill out a template.
type Listing struct {
// The name of the directory (the last element of the path)
Name string
// The full path of the request
Path string
// Whether the parent directory is browsable
CanGoUp bool
// The items (files and folders) in the path
Items []FileInfo
// The number of directories in the listing
NumDirs int
// The number of files (items that aren't directories) in the listing
NumFiles int
// Which sorting order is used
Sort string
// And which order
Order string
// If ≠0 then Items have been limited to that many elements
ItemsLimitedTo int
// Optional custom variables for use in browse templates
User interface{}
httpserver.Context
}
// Crumb represents part of a breadcrumb menu.
type Crumb struct {
Link, Text string
}
// Breadcrumbs returns l.Path where every element maps
// the link to the text to display.
func (l Listing) Breadcrumbs() []Crumb {
var result []Crumb
if len(l.Path) == 0 {
return result
}
// skip trailing slash
lpath := l.Path
if lpath[len(lpath)-1] == '/' {
lpath = lpath[:len(lpath)-1]
}
parts := strings.Split(lpath, "/")
for i := range parts {
txt := parts[i]
if i == 0 && parts[i] == "" {
txt = "/"
}
result = append(result, Crumb{Link: strings.Repeat("../", len(parts)-i-1), Text: txt})
}
return result
}
// HasExt returns whether the listing has one or more files with any of the specified suffixes
func (l Listing) HasExt(exts ...string) bool {
for _, item := range l.Items {
for _, ext := range exts {
if strings.HasSuffix(item.Name, ext) {
return true
}
}
}
return false
}
// HasItem returns whether the listing has an item with any of the specified names
func (l Listing) HasItem(names ...string) bool {
for _, item := range l.Items {
for _, name := range names {
if item.Name == name {
return true
}
}
}
return false
}
// HasMatch returns whether the listing has an item with a name matching the given regex
func (l Listing) HasMatch(pattern string) bool {
for _, item := range l.Items {
matched, _ := regexp.MatchString(pattern, item.Name)
if matched {
return true
}
}
return false
}
// FileInfo is the info about a particular file or directory
type FileInfo struct {
Name string
Size int64
URL string
ModTime time.Time
Mode os.FileMode
IsDir bool
IsSymlink bool
}
// HumanSize returns the size of the file as a human-readable string
// in IEC format (i.e. power of 2 or base 1024).
func (fi FileInfo) HumanSize() string {
return humanize.IBytes(uint64(fi.Size))
}
// HumanModTime returns the modified time of the file as a human-readable string.
func (fi FileInfo) HumanModTime(format string) string {
return fi.ModTime.Format(format)
}
// Implement sorting for Listing
func (l Listing) Len() int { return len(l.Items) }
func (l Listing) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] }
func (l Listing) Less(i, j int) bool {
a := l.Items[i]
b := l.Items[j]
// if both are dir or file sort normally
if a.IsDir == b.IsDir {
if a.Name == "SHA256SUMS" && b.Name != "SHA256SUMS" {
return false
} else if a.Name != "SHA256SUMS" && b.Name == "SHA256SUMS" {
return true
}
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
}
// always sort dir ahead of file
return a.IsDir
}
func directoryListing(files []os.FileInfo, canGoUp bool, urlPath string, config *Config) (Listing, bool) {
var (
fileinfos []FileInfo
dirCount, fileCount int
hasIndexFile bool
)
for _, f := range files {
name := f.Name()
for _, indexName := range config.Fs.IndexPages {
if name == indexName {
hasIndexFile = true
break
}
}
isDir := f.IsDir() || isSymlinkTargetDir(f, urlPath, config)
if isDir {
name += "/"
dirCount++
} else {
fileCount++
}
if config.Fs.IsHidden(f) {
continue
}
url := url.URL{Path: "./" + name} // prepend with "./" to fix paths with ':' in the name
fileinfos = append(fileinfos, FileInfo{
IsDir: isDir,
IsSymlink: isSymlink(f),
Name: f.Name(),
Size: f.Size(),
URL: url.String(),
ModTime: f.ModTime().UTC(),
Mode: f.Mode(),
})
}
return Listing{
Name: path.Base(urlPath),
Path: urlPath,
CanGoUp: canGoUp,
Items: fileinfos,
NumDirs: dirCount,
NumFiles: fileCount,
}, hasIndexFile
}
// isSymlink return true if f is a symbolic link
func isSymlink(f os.FileInfo) bool {
return f.Mode()&os.ModeSymlink != 0
}
// isSymlinkTargetDir return true if f's symbolic link target
// is a directory. Return false if not a symbolic link.
func isSymlinkTargetDir(f os.FileInfo, urlPath string, config *Config) bool {
if !isSymlink(f) {
return false
}
// a bit strange, but we want Stat thru the jailed filesystem to be safe
target, err := config.Fs.Root.Open(path.Join(urlPath, f.Name()))
if err != nil {
return false
}
defer target.Close()
targetInfo, err := target.Stat()
if err != nil {
return false
}
return targetInfo.IsDir()
}
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
// If so, control is handed over to ServeListing.
func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
// See if there's a browse configuration to match the path
var bc *Config
for i := range b.Configs {
if httpserver.Path(r.URL.Path).Matches(b.Configs[i].PathScope) {
bc = &b.Configs[i]
break
}
}
if bc == nil {
return b.Next.ServeHTTP(w, r)
}
// Browse works on existing directories; delegate everything else
requestedFilepath, err := bc.Fs.Root.Open(r.URL.Path)
if err != nil {
switch {
case os.IsPermission(err):
return http.StatusForbidden, err
case os.IsExist(err):
return http.StatusNotFound, err
default:
return b.Next.ServeHTTP(w, r)
}
}
defer requestedFilepath.Close()
info, err := requestedFilepath.Stat()
if err != nil {
switch {
case os.IsPermission(err):
return http.StatusForbidden, err
case os.IsExist(err):
return http.StatusGone, err
default:
return b.Next.ServeHTTP(w, r)
}
}
if !info.IsDir() {
return b.Next.ServeHTTP(w, r)
}
// Do not reply to anything else because it might be nonsensical
switch r.Method {
case http.MethodGet, http.MethodHead:
// proceed, noop
case "PROPFIND", http.MethodOptions:
return http.StatusNotImplemented, nil
default:
return b.Next.ServeHTTP(w, r)
}
// Browsing navigation gets messed up if browsing a directory
// that doesn't end in "/" (which it should, anyway)
u := *r.URL
if u.Path == "" {
u.Path = "/"
}
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
http.Redirect(w, r, u.String(), http.StatusMovedPermanently)
return http.StatusMovedPermanently, nil
}
return b.ServeListing(w, r, requestedFilepath, bc)
}
func (b Browse) loadDirectoryContents(requestedFilepath http.File, urlPath string, config *Config) (*Listing, bool, error) {
files, err := requestedFilepath.Readdir(-1)
if err != nil {
return nil, false, err
}
// Determine if user can browse up another folder
var canGoUp bool
curPathDir := path.Dir(strings.TrimSuffix(urlPath, "/"))
for _, other := range b.Configs {
if strings.HasPrefix(curPathDir, other.PathScope) {
canGoUp = true
break
}
}
// Assemble listing of directory contents
listing, hasIndex := directoryListing(files, canGoUp, urlPath, config)
return &listing, hasIndex, nil
}
// ServeListing returns a formatted view of 'requestedFilepath' contents'.
func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFilepath http.File, bc *Config) (int, error) {
listing, containsIndex, err := b.loadDirectoryContents(requestedFilepath, r.URL.Path, bc)
if err != nil {
switch {
case os.IsPermission(err):
return http.StatusForbidden, err
case os.IsExist(err):
return http.StatusGone, err
default:
return http.StatusInternalServerError, err
}
}
if containsIndex && !b.IgnoreIndexes { // directory isn't browsable
return b.Next.ServeHTTP(w, r)
}
listing.Context = httpserver.Context{
Root: bc.Fs.Root,
Req: r,
URL: r.URL,
}
listing.User = bc.Variables
sort.Sort(listing)
var buf *bytes.Buffer
acceptHeader := strings.ToLower(strings.Join(r.Header["Accept"], ","))
switch {
case strings.Contains(acceptHeader, "application/json"):
if buf, err = b.formatAsJSON(listing, bc); err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
default: // There's no 'application/json' in the 'Accept' header; browse normally
if buf, err = b.formatAsHTML(listing, bc); err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
buf.WriteTo(w)
return http.StatusOK, nil
}
func (b Browse) formatAsJSON(listing *Listing, bc *Config) (*bytes.Buffer, error) {
marsh, err := json.Marshal(listing.Items)
if err != nil {
return nil, err
}
buf := new(bytes.Buffer)
_, err = buf.Write(marsh)
return buf, err
}
func (b Browse) formatAsHTML(listing *Listing, bc *Config) (*bytes.Buffer, error) {
buf := new(bytes.Buffer)
err := bc.Template.Execute(buf, listing)
return buf, err
}
<!DOCTYPE html>
<html>
<head>
<title>~/Media{{.Path}}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="/resources/style.css"/>
{{define "svg_folder"}}
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" /></svg>
{{end}}
{{define "svg_video"}}
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M18,4L20,8H17L15,4H13L15,8H12L10,4H8L10,8H7L5,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V4H18Z" /></svg>
{{end}}
{{define "svg_file"}}
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z" /></svg>
{{end}}
{{define "svg_hash"}}
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M3,5A2,2 0 0,1 5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5M7,18H9L9.35,16H13.35L13,18H15L15.35,16H17.35L17.71,14H15.71L16.41,10H18.41L18.76,8H16.76L17.12,6H15.12L14.76,8H10.76L11.12,6H9.12L8.76,8H6.76L6.41,10H8.41L7.71,14H5.71L5.35,16H7.35L7,18M10.41,10H14.41L13.71,14H9.71L10.41,10Z" /></svg>
{{end}}
{{define "svg_movie"}}
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M18,9H16V7H18M18,13H16V11H18M18,17H16V15H18M8,9H6V7H8M8,13H6V11H8M8,17H6V15H8M18,3V5H16V3H8V5H6V3H4V21H6V19H8V21H16V19H18V21H20V3H18Z" /></svg>
{{end}}
{{define "svg_tv"}}
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M8.16,3L6.75,4.41L9.34,7H4C2.89,7 2,7.89 2,9V19C2,20.11 2.89,21 4,21H20C21.11,21 22,20.11 22,19V9C22,7.89 21.11,7 20,7H14.66L17.25,4.41L15.84,3L12,6.84L8.16,3M4,9H17V19H4V9M19.5,9A1,1 0 0,1 20.5,10A1,1 0 0,1 19.5,11A1,1 0 0,1 18.5,10A1,1 0 0,1 19.5,9M19.5,12A1,1 0 0,1 20.5,13A1,1 0 0,1 19.5,14A1,1 0 0,1 18.5,13A1,1 0 0,1 19.5,12Z" /></svg>
{{end}}
</head>
<body>
<header>
<h1>
~ /{{range $i, $crumb := .Breadcrumbs}}<a href="{{html $crumb.Link}}">{{if eq $i 0}}Media{{else}}{{html $crumb.Text}}{{end}}</a>/{{end}}
</h1>
<div id="meta">
<input type="text" placeholder="Search" id="filter" onkeyup='filter()'>
</div>
</header>
<main>
{{$notTor := ne .Host "andrew2cezcusdq3.onion"}}
{{$isMovies := .PathMatches "/Movies"}}
{{$noVideos := and (not (.HasExt ".mkv" ".mp4")) (ne .Path "/")}}
{{if and $notTor (or $isMovies $noVideos)}}
<div id="shelf">
{{range .Items}}
{{if or .IsDir (eq ($.Ext .Name) ".mkv" ".mp4" ".txt")}}
<a class="coverart" href="{{html .URL}}">
{{if $.PathMatches "/TV"}}
<img src="{{html .URL}}poster.jpg" alt="">
{{else}}
<img src="/resources/movie_thumbs/{{html ($.StripExt .Name)}}.png" alt="">
{{end}}
<span class="name">{{if .IsDir}}{{html .Name}}{{else}}{{html ($.StripExt .Name)}}{{end}}</span>
</a>
{{end}}
{{end}}
</div>
{{if .HasItem "SHA256SUMS"}}
<a href="./SHA256SUMS">
<p style="text-align: center; margin-top:1em; margin-bottom: 1.5em">
SHA-256 Checksums
</p>
</a>
{{end}}
{{else}}
<div id="listing">
<table aria-describedby="summary">
<thead>
<tr>
<th>Name</th>
<th class="hideable">Size</th>
</tr>
</thead>
<tbody>
{{range .Items}}
{{if and .IsDir (ne .Name "resources")}}
<tr class="file">
<td>
<a href="{{html .URL}}">
{{if eq .Name "Movies"}}
{{template "svg_movie"}}
{{else if eq .Name "TV"}}
{{template "svg_tv"}}
{{else}}
{{template "svg_folder"}}
{{end}}
<span class="name">{{html .Name}}</span>
</a>
</td>
<td class="hideable">&mdash;</td>
</tr>
{{else if or (eq ($.Ext .Name) ".mkv" ".mp4" ".txt") (eq .Name "SHA256SUMS")}}
<tr class="file">
<td>
<a href="{{html .URL}}">
{{if eq ($.Ext .Name) ".mkv" ".mp4"}}
{{template "svg_video"}}
{{else if eq .Name "SHA256SUMS"}}
{{template "svg_hash"}}
{{else}}
{{template "svg_file"}}
{{end}}
<span class="name">
{{- if eq .Name "SHA256SUMS" -}}
SHA-256 Checksums
{{- else -}}
{{- html ($.StripExt .Name) -}}
{{- end -}}
</span>
</a>
</td>
<td class="hideable">{{.HumanSize}}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
{{end}}
</main>
<script>
var filterEl = document.getElementById('filter');
filterEl.focus();
function filter() {
var q = filterEl.value.trim().toLowerCase();
var elems = document.querySelectorAll('tr.file');
if (elems.length == 0) {
elems = document.querySelectorAll('.coverart');
}
elems.forEach(function(el) {
if (!q) {
el.style.display = '';
return;
}
var nameEl = el.querySelector('.name');
var nameVal = nameEl.textContent.trim().toLowerCase();
if (nameVal.indexOf(q) !== -1) {
el.style.display = '';
} else {
el.style.display = 'none';
}
});
}
</script>
</body>
</html>
* {
padding: 0;
margin: 0;
}
body {
font-family: sans-serif;
text-rendering: optimizespeed;
background: #212121;
}
a {
color: #bbb;
text-decoration: none;
}
a:hover {
}
header {
padding-left: 5%;
padding-right: 5%;
padding-top: 10px;
padding-bottom: 10px;
background-color: #0f0f0f;
border-bottom: 1px solid #9C9C9C;
}
th:first-child,
td:first-child {
padding-left: 5%;
}
th:last-child,
td:last-child {
padding-right: 5%;
}
h1 {
font-size: 20px;
font-weight: normal;
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
color: #7f7f7f;
opacity: 1;
}
h1 a {
color: #fff;
margin: 0.35em 0.35em;
}
h1 a:hover {
text-decoration: underline;
}
main {
display: block;
}
#meta {
font-size: 12px;
font-family: Verdana, sans-serif;
padding-top: 10px;
}
#filter {
box-sizing: border-box;
padding: 4px;
border: 1px solid #ccc;
background-color: #000;
color: #afafaf;
max-width: 500px;
width: 90%;
display: block;
margin: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
tr {
border-bottom: 1px dashed #3f3f3f;
}
tbody tr:hover {
background-color: #303030;
}
th,
td {
text-align: left;
padding: 10px 0;
}
th {
padding-top: 15px;
padding-bottom: 15px;
margin-right: 5%;
font-size: 16px;
white-space: nowrap;
color: #fff;
opacity: 0.5;
}
td {
white-space: nowrap;
font-size: 14px;
}
td:first-child {
width: auto;
}
th:last-child,
td:last-child {
text-align: right;
}
td:first-child svg {
position: absolute;
}
td .name,
tr .hideable {
margin-left: 1.75em;
word-break: break-all;
overflow-wrap: break-word;
white-space: pre-wrap;
color: #fff;
opacity: 0.7;
}
td:hover .icon,
td:hover a .name,
tbody tr:hover .hideable {
color:#fff;
opacity: 1.0;
}
.coverart {
width: 200px;
display: flex;
margin: 10px;
box-shadow: 0px 1px 4px 2px rgba(0,0,0,0.2);
transition: all 0.2s cubic-bezier(0.0, 0.0, 0.2, 1);
user-select: none; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none;
cursor: pointer;
transform: translateZ(0);
min-height: 250px;
background-color: #000;
}
.coverart:hover {
transform: scale(1.2);
z-index: 9999;
box-shadow: 0px 1px 8px 4px rgba(0,0,0,0.2);
}
.coverart:active {
transform: scale(0.96);
}
.coverart img {
width: 100%;
height: 100%;
pointer-events: none;
}
#shelf {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
align-content: center;
flex: 1;
flex-wrap: wrap;
margin: auto;
margin-top: 10px;
margin-bottom:25px;
max-width: 1200px;
flex-basis: auto;
}
.icon {
width: 1.5em;
height: 1.5em;
opacity: 0.38;
}
@media (max-width: 600px) {
.hideable {
display: none;
}
.coverart {
max-width: 40%;
height: 100%;
min-height: 100px;
}
.shelf {
max-width: 90%;
}
}
.coverart .name {
position: absolute;
text-align: center;
margin-left: 0;
margin-right: 0;
width: 100%;
z-index: -9999;
color: #fff;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment