Files
datascape/main.go

275 lines
6.2 KiB
Go

package main
import (
"bytes"
"embed"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
//go:embed assets/*
var assets embed.FS
var md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
type crumb struct{ Name, URL string }
type entry struct{ Icon, Name, URL, Meta string }
type pageData struct {
Title string
Crumbs []crumb
CanEdit bool
EditMode bool
PostURL string
RawContent string
Content template.HTML
Entries []entry
}
func main() {
addr := flag.String("addr", ":8080", "listen address")
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password")
flag.Parse()
root, err := filepath.Abs(*wikiDir)
if err != nil {
log.Fatal(err)
}
if err := os.MkdirAll(root, 0755); err != nil {
log.Fatal(err)
}
h := &handler{root: root, user: *user, pass: *pass}
staticFS, _ := fs.Sub(assets, "assets")
http.Handle("/_/", http.StripPrefix("/_/", http.FileServer(http.FS(staticFS))))
http.Handle("/", h)
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
log.Fatal(http.ListenAndServe(*addr, nil))
}
type handler struct {
root, user, pass string
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.user != "" {
u, p, ok := r.BasicAuth()
if !ok || u != h.user || p != h.pass {
w.Header().Set("WWW-Authenticate", `Basic realm="datascape"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
urlPath := path.Clean("/" + r.URL.Path)
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
// Security: ensure the resolved path stays within root.
rel, err := filepath.Rel(h.root, fsPath)
if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
info, err := os.Stat(fsPath)
if err != nil {
http.NotFound(w, r)
return
}
if info.IsDir() {
h.serveDir(w, r, urlPath, fsPath)
} else {
http.ServeFile(w, r, fsPath)
}
}
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
_, editMode := r.URL.Query()["edit"]
if r.Method == http.MethodPost {
h.handlePost(w, r, urlPath, fsPath)
return
}
indexPath := filepath.Join(fsPath, "index.md")
rawMD, _ := os.ReadFile(indexPath)
var rendered template.HTML
if len(rawMD) > 0 && !editMode {
var buf bytes.Buffer
if err := md.Convert(rawMD, &buf); err == nil {
rendered = template.HTML(buf.String())
}
}
var entries []entry
if !editMode {
entries = listEntries(fsPath, urlPath)
}
postURL := urlPath
if urlPath == "/" {
postURL = "/"
}
data := pageData{
Title: pageTitle(urlPath),
Crumbs: buildCrumbs(urlPath),
CanEdit: true,
EditMode: editMode,
PostURL: postURL,
RawContent: string(rawMD),
Content: rendered,
Entries: entries,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("template error: %v", err)
}
}
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
content := r.FormValue("content")
indexPath := filepath.Join(fsPath, "index.md")
if strings.TrimSpace(content) == "" {
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, urlPath, http.StatusSeeOther)
}
func listEntries(fsPath, urlPath string) []entry {
entries, err := os.ReadDir(fsPath)
if err != nil {
return nil
}
var folders, files []entry
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
entryURL := path.Join(urlPath, name)
if e.IsDir() {
folders = append(folders, entry{
Icon: "📁",
Name: name,
URL: entryURL + "/",
Meta: info.ModTime().Format("2006-01-02"),
})
} else {
if name == "index.md" {
continue // rendered above, don't list it
}
files = append(files, entry{
Icon: fileIcon(name),
Name: name,
URL: entryURL,
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
})
}
}
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
return append(folders, files...)
}
func fileIcon(name string) string {
ext := strings.ToLower(path.Ext(name))
switch ext {
case ".md":
return "📄"
case ".pdf":
return "📕"
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
return "🖼"
case ".mp4", ".mkv", ".avi", ".mov":
return "🎬"
case ".mp3", ".flac", ".ogg", ".wav":
return "🎵"
case ".zip", ".tar", ".gz", ".7z":
return "📦"
default:
return "📎"
}
}
func formatSize(b int64) string {
switch {
case b < 1024:
return fmt.Sprintf("%d B", b)
case b < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(b)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
}
}
func buildCrumbs(urlPath string) []crumb {
if urlPath == "/" {
return nil
}
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
crumbs := make([]crumb, len(parts))
for i, p := range parts {
crumbs[i] = crumb{
Name: p,
URL: "/" + strings.Join(parts[:i+1], "/") + "/",
}
}
return crumbs
}
func pageTitle(urlPath string) string {
if urlPath == "/" {
return "Datascape"
}
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
return parts[len(parts)-1]
}