package main import ( "embed" "flag" "html/template" "io/fs" "log" "net/http" "os" "path" "path/filepath" "strings" ) //go:embed assets/* var assets embed.FS var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html")) // specialPage is the result returned by a pageTypeHandler. // Content is injected into the page after the standard markdown content. // SuppressListing hides the default file/folder listing. type specialPage struct { Content template.HTML SuppressListing bool } // pageTypeHandler is implemented by each special folder type (diary, gallery, …). // handle returns nil when the handler does not apply to the given path. type pageTypeHandler interface { handle(root, fsPath, urlPath string) *specialPage } // pageTypeHandlers is the registry. Each type registers itself via init(). var pageTypeHandlers []pageTypeHandler 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 { if os.IsNotExist(err) && strings.HasSuffix(r.URL.Path, "/") { h.serveDir(w, r, urlPath, fsPath) return } 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 { rendered = renderMarkdown(rawMD) } var special *specialPage if !editMode { for _, ph := range pageTypeHandlers { if special = ph.handle(h.root, fsPath, urlPath); special != nil { break } } } var entries []entry if !editMode && (special == nil || !special.SuppressListing) { entries = listEntries(fsPath, urlPath) } title := pageTitle(urlPath) if heading := extractFirstHeading(rawMD); heading != "" { title = heading } var specialContent template.HTML if special != nil { specialContent = special.Content } data := pageData{ Title: title, Crumbs: buildCrumbs(urlPath), CanEdit: true, EditMode: editMode, PostURL: urlPath, RawContent: string(rawMD), Content: rendered, Entries: entries, SpecialContent: specialContent, } 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.MkdirAll(fsPath, 0755); err != nil { http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) return } 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) } // readPageSettings parses a .page-settings file in dir. // Returns nil if the file does not exist. // Format: one "key = value" pair per line; lines starting with # are comments. func readPageSettings(dir string) *pageSettings { data, err := os.ReadFile(filepath.Join(dir, ".page-settings")) if err != nil { return nil } s := &pageSettings{} for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } switch strings.TrimSpace(parts[0]) { case "type": s.Type = strings.TrimSpace(parts[1]) } } return s }