commit 06b6c5b00c0b4676594f5f418de8225b4009c253 Author: bakonpancakz Date: Sat May 23 16:50:43 2026 -0700 Initial Release diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..becc7c8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "golang.go" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..22144ff --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.formatOnSave": true, + "editor.rulers": [ + 120 + ], + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.associations": {}, + "[css]": { + "editor.defaultFormatter": "vscode.css-language-features" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/README b/README new file mode 100644 index 0000000..1c83db5 --- /dev/null +++ b/README @@ -0,0 +1,21 @@ +----------------- + pancakz-browser +----------------- + +A Miku flavored file browser inspired by THE FLAVOR FOLEY FILES (https://flavorfoley.com/FFF/) + + This application contains my personal branding, modify the files in the 'private' directory to change this. + +[ Config ] + +Configure the service using environment variables: + +| Name | Default | Description +| HTTP_ADDRESS | 127.0.0.1:9000 | Address and Port to use for requests +| HTTP_DIRECTORY | | Starting directory to list and serve files from + +[ Credits ] + +Original art that was modified for the background is available here: +https://www.pixiv.net/en/artworks/13891224 + diff --git a/main.go b/main.go new file mode 100644 index 0000000..ec4c058 --- /dev/null +++ b/main.go @@ -0,0 +1,314 @@ +package main + +import ( + "bytes" + "embed" + "fmt" + "io" + "log" + "mime" + "net/http" + "net/url" + "os" + "path" + "slices" + "strconv" + "strings" + "text/template" + "time" +) + +var ( + //go:embed private/* + privateDirectory embed.FS + serveHash = fmt.Sprintf("%x", time.Now().Unix()) + serveDirectory = os.Getenv("HTTP_DIRECTORY") + serveAddress = os.Getenv("HTTP_ADDRESS") + tmpl = template.Must( + template. + New("directory.html"). + Funcs(template.FuncMap{ + "EncodeURI": url.PathEscape, + "GetEntryIcon": getEntryIcon, + "GetEntrySizeHuman": getEntrySizeHuman, + }). + ParseFS(privateDirectory, "private/directory.html"), + ) +) + +type Entry struct { + Name string + IsDir bool + Size int64 + ModTime time.Time +} + +func serveFileError(w http.ResponseWriter, err error) { + if os.IsNotExist(err) { + w.WriteHeader(http.StatusNotFound) + return + } + if os.IsPermission(err) { + w.WriteHeader(http.StatusForbidden) + return + } + log.Println("Filesystem Error:", err) + http.Error(w, "Server Error", http.StatusInternalServerError) +} + +func parentPath(urlPath string) string { + trimmed := strings.TrimSuffix(urlPath, "/") + if trimmed == "" { + return "" + } + idx := strings.LastIndex(trimmed, "/") + if idx <= 0 { + return "/" + } + return trimmed[:idx+1] +} + +func getEntrySizeHuman(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +func getEntryIcon(name string, isDir bool) string { + if isDir { + return "icon_folder.png" + } + switch strings.ToLower(path.Ext(name)) { + // Custom Filetypes + case ".redir": + return "icon_redirect.png" + + // Default Filetypes + case ".mp3", ".flac", ".ogg", ".wav", ".aac", ".opus", ".m4a": + return "icon_audio.png" + case ".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v": + return "icon_video.png" + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tiff": + return "icon_photo.png" + case ".zip", ".tar", ".gz", ".7z", ".rar", ".zst", ".xz", ".bz2": + return "icon_archive.png" + case ".exe", ".elf", ".bin", ".out", ".appimage", ".deb", ".rpm": + return "icon_program.png" + case ".pdf", ".txt", ".md", ".srt", ".vtt", ".ass", ".log": + return "icon_document.png" + case ".html", ".ts", ".js", ".go", ".rs", ".pug": + return "icon_script.png" + case ".json", ".xml", ".yaml", ".toml", ".ini", ".cfg": + return "icon_config.png" + default: + return "icon_generic.png" + } +} + +func init() { + if serveAddress == "" { + serveAddress = "127.0.0.1:8080" + } + if serveDirectory == "" { + serveDirectory = "./public" + } +} + +func main() { + mux := http.NewServeMux() + prv := http.FileServerFS(privateDirectory) + + mux.HandleFunc("/private/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + prv.ServeHTTP(w, r) + }) + + mux.HandleFunc("/preferences/sort", func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + http.SetCookie(w, &http.Cookie{ + Name: "sort", + Value: r.FormValue("sort"), + Path: "/", + MaxAge: 31536000, + }) + http.Redirect(w, r, r.FormValue("redirect"), http.StatusSeeOther) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + + filepath := path.Join(serveDirectory, path.Clean(r.URL.Path)) + filestat, err := os.Stat(filepath) + if err != nil { + serveFileError(w, err) + return + } + + // Use Browser Cache (if possible) + modtime := filestat.ModTime().UTC() + modhash := fmt.Sprint(modtime.Unix()) + if r.Header.Get("If-None-Match") == modhash { + w.WriteHeader(http.StatusNotModified) + return + } + if ts, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since")); err == nil { + if !modtime.After(ts) { + w.WriteHeader(http.StatusNotModified) + return + } + } + if ts, err := time.Parse(http.TimeFormat, r.Header.Get("If-Unmodified-Since")); err == nil { + if modtime.After(ts) { + w.WriteHeader(http.StatusPreconditionFailed) + return + } + } + + if filestat.IsDir() { + // List Directory Contents + + indexPath := path.Join(filepath, "index.html") + if _, err := os.Stat(indexPath); err == nil { + http.ServeFile(w, r, indexPath) + return + } + + dirEntries, err := os.ReadDir(filepath) + if err != nil { + serveFileError(w, err) + return + } + + // Entry Information + entries := make([]Entry, 0, len(dirEntries)) + for _, entry := range dirEntries { + info, err := entry.Info() + if err != nil { + continue + } + + // Check Symbolic Link + isDir := entry.IsDir() + if entry.Type()&os.ModeSymlink != 0 { + if stat, err := os.Stat(path.Join(filepath, entry.Name())); err == nil { + isDir = stat.IsDir() + info = stat + } + } + + entries = append(entries, Entry{ + Name: entry.Name(), + IsDir: isDir, + Size: info.Size(), + ModTime: info.ModTime().UTC(), + }) + } + + // Entry Sorting + sortOrder := "alpha" + if c, err := r.Cookie("sort"); err == nil && c.Value != "" { + sortOrder = c.Value + } + slices.SortFunc(entries, func(a, b Entry) int { + // Folders are always on top + if a.IsDir != b.IsDir { + if a.IsDir { + return -1 + } + return 1 + } + // Use + if sortOrder == "modified" { + if a.ModTime.Equal(b.ModTime) { + return strings.Compare(a.Name, b.Name) + } + if a.ModTime.After(b.ModTime) { + return -1 + } + return 1 + } + return strings.Compare(a.Name, b.Name) + }) + + // Directory Listing + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, map[string]any{ + "Path": r.URL.Path, + "Parent": parentPath(r.URL.Path), + "Item": entries, + "ItemCount": len(entries), + "Version": serveHash, + "Sort": sortOrder, + }); err != nil { + serveFileError(w, err) + return + } + + } else { + // Serve File Contents + + f, err := os.Open(filepath) + if err != nil { + serveFileError(w, err) + return + } + defer f.Close() + + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(filepath))) + w.Header().Set("Last-Modified", modtime.Format(http.TimeFormat)) + w.Header().Set("ETag", modhash) + + // Special File Handler: .redir + if strings.EqualFold(path.Ext(filepath), ".redir") { + raw, _ := io.ReadAll(f) + uri, err := url.Parse(string(bytes.TrimSpace(raw))) + if err != nil { + serveFileError(w, err) + return + } + http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect) + return + } + + // Serve Byte Range (if applicable) + rangeHeader := r.Header.Get("Range") + if rangeHeader != "" { + var start, end int64 + fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end) + if end == 0 || end >= filestat.Size() { + end = filestat.Size() - 1 + } + length := end - start + 1 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, filestat.Size())) + w.Header().Set("Content-Length", strconv.FormatInt(length, 10)) + w.WriteHeader(http.StatusPartialContent) + f.Seek(start, io.SeekStart) + io.CopyN(w, f, length) + return + } + + // Serve Raw Contents + if _, err := io.Copy(w, f); err != nil { + serveFileError(w, err) + return + } + } + + }) + + svr := http.Server{ + Handler: mux, + Addr: serveAddress, + } + log.Printf("Listening @ %s\n", serveAddress) + if err := svr.ListenAndServe(); err != nil { + log.Fatalf("Listen Error: %s\n", err) + } +} diff --git a/private/bg_graphic.png b/private/bg_graphic.png new file mode 100644 index 0000000..ea288da Binary files /dev/null and b/private/bg_graphic.png differ diff --git a/private/bg_pattern.png b/private/bg_pattern.png new file mode 100644 index 0000000..1b00030 Binary files /dev/null and b/private/bg_pattern.png differ diff --git a/private/directory.html b/private/directory.html new file mode 100644 index 0000000..aef3112 --- /dev/null +++ b/private/directory.html @@ -0,0 +1,73 @@ + + + + + + + + + Browser: {{ .Path }} + + + + +
+
+ {{ .Path }} +
+ Links: + Top + Homepage + + Sort: +
+ + + +
+
+ + + +
+
+
+
+
+ + {{ if .Parent }} + +
+ ../ +
+ {{ end }} + + {{ range .Item }} + {{ if .IsDir }} + +
+ {{ .Name }} +
+ {{ else }} + + + {{ .Name }} + + {{ end }} + {{ end }} + +
+
+
+
+ + + diff --git a/private/favicon.png b/private/favicon.png new file mode 100644 index 0000000..3df34a0 Binary files /dev/null and b/private/favicon.png differ diff --git a/private/icon_archive.png b/private/icon_archive.png new file mode 100644 index 0000000..729e8e2 Binary files /dev/null and b/private/icon_archive.png differ diff --git a/private/icon_audio.png b/private/icon_audio.png new file mode 100644 index 0000000..82e9e8f Binary files /dev/null and b/private/icon_audio.png differ diff --git a/private/icon_config.png b/private/icon_config.png new file mode 100644 index 0000000..7b46843 Binary files /dev/null and b/private/icon_config.png differ diff --git a/private/icon_document.png b/private/icon_document.png new file mode 100644 index 0000000..d57f5d4 Binary files /dev/null and b/private/icon_document.png differ diff --git a/private/icon_folder.png b/private/icon_folder.png new file mode 100644 index 0000000..136be6a Binary files /dev/null and b/private/icon_folder.png differ diff --git a/private/icon_folder_open.png b/private/icon_folder_open.png new file mode 100644 index 0000000..5852574 Binary files /dev/null and b/private/icon_folder_open.png differ diff --git a/private/icon_generic.png b/private/icon_generic.png new file mode 100644 index 0000000..0cf0ebb Binary files /dev/null and b/private/icon_generic.png differ diff --git a/private/icon_photo.png b/private/icon_photo.png new file mode 100644 index 0000000..dd2e0f7 Binary files /dev/null and b/private/icon_photo.png differ diff --git a/private/icon_program.png b/private/icon_program.png new file mode 100644 index 0000000..4660f9a Binary files /dev/null and b/private/icon_program.png differ diff --git a/private/icon_redirect.png b/private/icon_redirect.png new file mode 100644 index 0000000..3370282 Binary files /dev/null and b/private/icon_redirect.png differ diff --git a/private/icon_script.png b/private/icon_script.png new file mode 100644 index 0000000..7e80da6 Binary files /dev/null and b/private/icon_script.png differ diff --git a/private/icon_video.png b/private/icon_video.png new file mode 100644 index 0000000..34d2d41 Binary files /dev/null and b/private/icon_video.png differ diff --git a/private/styles.css b/private/styles.css new file mode 100644 index 0000000..9cb59b0 --- /dev/null +++ b/private/styles.css @@ -0,0 +1,109 @@ +body { + margin: 0; +} + +::-webkit-scrollbar { + display: none; +} + +a, +button { + border: 0; + padding: 0; + font-family: Times, serif; + background: transparent; + text-decoration: underline; + color: blue; + cursor: pointer; + font-size: 1em; +} + +div.foreground { + background: #88cecb; + box-sizing: border-box; + padding: 16px; + min-height: 180px; +} + +div.pattern { + background: url("/private/bg_pattern.png?v={{ .Version }}"); + background-repeat: repeat-x; + height: 152px; +} + +div.graphic { + background: url("/private/bg_graphic.png?v={{ .Version }}"); + width: 256px; + height: 259px; + position: absolute; + margin-top: 32px; + right: 2%; +} + +div.header { + display: flex; + justify-content: space-between; +} + +div.header div.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +div.directory { + display: flex; + flex-wrap: wrap; +} + +a.entry[data-type="directory"] div.icon { + background-size: 32px 32px; + height: 32px; + width: 32px; +} + +a.entry { + display: inline-flex; + flex-direction: column; + align-items: center; + width: 100px; + padding: 8px; + text-align: center; + text-decoration: none; + color: inherit; +} + +a.entry img.icon { + height: 32px; + width: 32px; +} + +a.entry span.filename { + font-size: 0.8em; + margin-top: 4px; + color: blue; + text-decoration: underline; + word-break: break-word; +} + +@media (max-width: 600px) { + + a.entry { + padding: 4px; + width: 100%; + text-align: left; + flex-direction: unset; + gap: 8px; + } + + a.entry img.icon, + a.entry[data-type="directory"] div.icon { + background-size: 1em 1em; + height: 1em; + width: 1em; + } + + a.entry span.filename { + font-size: 1em; + } +} diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..7663cf6 --- /dev/null +++ b/public/.gitkeep @@ -0,0 +1,4 @@ +I KEPT A BULLET IN THE CHAMBER FOR A RAINY DAY +BUT WHAT'S THE POINT IN PLANNING FOR IT ANYWAY +I'VE GOT TOO MANY COLORS FOR A SHADE OF GREY +AND I CAN MAKE IT AUTUMN EVERY SINGLE DAY \ No newline at end of file