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 = `` iconDoc template.HTML = `` iconImage template.HTML = `` iconVideo template.HTML = `` iconAudio template.HTML = `` iconArchive template.HTML = `` iconGeneric template.HTML = `` ) 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] }