287 lines
8.6 KiB
Go
287 lines
8.6 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 template.HTML
|
|
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() {
|
|
if urlPath != "/" {
|
|
urlPath += "/"
|
|
}
|
|
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)
|
|
}
|
|
|
|
data := pageData{
|
|
Title: pageTitle(urlPath),
|
|
Crumbs: buildCrumbs(urlPath),
|
|
CanEdit: true,
|
|
EditMode: editMode,
|
|
PostURL: urlPath,
|
|
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: iconFolder,
|
|
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...)
|
|
}
|
|
|
|
// Pixel-art SVG icons — outlined, crispEdges, uses currentColor.
|
|
const (
|
|
iconFolder template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>`
|
|
iconDoc template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4M5 8h6M5 11h4"/></svg>`
|
|
iconImage template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="2" width="14" height="12"/><path d="M1 11l4-4 3 3 2-2 5 5"/><rect x="10" y="4" width="2" height="2" fill="currentColor" stroke="none"/></svg>`
|
|
iconVideo template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="3" width="14" height="10"/><path d="M6 6v4l5-2z" fill="currentColor" stroke="none"/></svg>`
|
|
iconAudio template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="currentColor" stroke="none" shape-rendering="crispEdges"><path d="M2 6h3l4-3v10l-4-3H2z"/><rect x="11" y="5" width="2" height="1"/><rect x="11" y="7" width="3" height="1"/><rect x="11" y="9" width="2" height="1"/></svg>`
|
|
iconArchive template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="1" width="14" height="4"/><path d="M1 5v10h14V5M7 3h2"/><rect x="7" y="6" width="2" height="2" fill="currentColor" stroke="none"/><rect x="7" y="9" width="2" height="1" fill="currentColor" stroke="none"/></svg>`
|
|
iconGeneric template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4"/></svg>`
|
|
)
|
|
|
|
func fileIcon(name string) template.HTML {
|
|
ext := strings.ToLower(path.Ext(name))
|
|
switch ext {
|
|
case ".md":
|
|
return iconDoc
|
|
case ".pdf":
|
|
return iconDoc
|
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
|
return iconImage
|
|
case ".mp4", ".mkv", ".avi", ".mov":
|
|
return iconVideo
|
|
case ".mp3", ".flac", ".ogg", ".wav":
|
|
return iconAudio
|
|
case ".zip", ".tar", ".gz", ".7z":
|
|
return iconArchive
|
|
default:
|
|
return iconGeneric
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|