package main import ( "bytes" "embed" "flag" "fmt" "html/template" "io/fs" "log" "net/http" "net/url" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" "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")) // Diary sub-templates โ€” executed into a buffer and injected as DiaryContent. var diaryYearTmpl = template.Must(template.New("diary-year").Parse( `{{range .Months}}

{{.Name}}{{if .PhotoCount}} ({{.PhotoCount}} photos){{end}}

{{end}}`, )) var diaryMonthTmpl = template.Must(template.New("diary-month").Parse( `{{range .Days}}

{{if .URL}}{{.Heading}}{{else}}{{.Heading}}{{end}}

{{if .Content}}
{{.Content}}
{{end}}{{if .Photos}}
{{range .Photos}}{{.Name}}{{end}}
{{end}}
{{end}}`, )) var diaryDayTmpl = template.Must(template.New("diary-day").Parse( `{{if .Photos}}
{{range .Photos}}{{.Name}}{{end}}
{{end}}`, )) 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 DiaryContent template.HTML } // pageSettings holds the parsed contents of a .page-settings file. type pageSettings struct { Type string } // diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix. type diaryPhoto struct { Date time.Time Name string URL string } type diaryMonthSummary struct { Name string URL string PhotoCount int } type diaryDaySection struct { Heading string URL string Content template.HTML Photos []diaryPhoto } type diaryYearData struct{ Months []diaryMonthSummary } type diaryMonthData struct{ Days []diaryDaySection } type diaryDayData struct{ Photos []diaryPhoto } 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) } diaryDepth, isDiary := h.findDiaryContext(fsPath) // For diary sub-pages (depth > 0) the diary content replaces the file listing. var entries []entry if !editMode && (!isDiary || diaryDepth == 0) { entries = listEntries(fsPath, urlPath) } var diaryContent template.HTML if !editMode && isDiary { switch diaryDepth { case 1: diaryContent = h.renderDiaryYear(fsPath, urlPath) case 2: diaryContent = h.renderDiaryMonth(fsPath, urlPath) case 3: diaryContent = h.renderDiaryDay(fsPath, urlPath) } } title := pageTitle(urlPath) if h := extractFirstHeading(rawMD); h != "" { title = h } data := pageData{ Title: title, Crumbs: buildCrumbs(urlPath), CanEdit: true, EditMode: editMode, PostURL: urlPath, RawContent: string(rawMD), Content: rendered, Entries: entries, DiaryContent: diaryContent, } 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 } // findDiaryContext walks up from fsPath toward h.root looking for a // .page-settings file with type=diary. Returns the depth of fsPath // relative to the diary root, and whether a diary root was found at all. // depth=0 means fsPath itself is the diary root. func (h *handler) findDiaryContext(fsPath string) (int, bool) { current := fsPath for depth := 0; ; depth++ { s := readPageSettings(current) if s != nil && s.Type == "diary" { return depth, true } if current == h.root { break } parent := filepath.Dir(current) if parent == current { break } current = parent } return 0, false } // yearPhotos returns all photos in yearFsPath whose filename starts with // a YYYY-MM-DD date prefix. yearURLPath is the corresponding URL prefix. var photoExts = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, } func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto { entries, err := os.ReadDir(yearFsPath) if err != nil { return nil } var photos []diaryPhoto for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !photoExts[strings.ToLower(filepath.Ext(name))] { continue } if len(name) < 10 { continue } t, err := time.Parse("2006-01-02", name[:10]) if err != nil { continue } photos = append(photos, diaryPhoto{ Date: t, Name: name, URL: path.Join(yearURLPath, url.PathEscape(name)), }) } return photos } // extractFirstHeading returns the text of the first ATX heading in raw markdown, // or an empty string if none is found. func extractFirstHeading(raw []byte) string { for _, line := range strings.SplitN(string(raw), "\n", 50) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "#") { continue } text := strings.TrimSpace(strings.TrimLeft(trimmed, "#")) if text != "" { return text } } return "" } // stripFirstHeading removes the first ATX heading line from raw markdown. func stripFirstHeading(raw []byte) []byte { lines := strings.Split(string(raw), "\n") for i, line := range lines { if strings.HasPrefix(strings.TrimSpace(line), "#") { result := append(lines[:i:i], lines[i+1:]...) return []byte(strings.TrimLeft(strings.Join(result, "\n"), "\n")) } } return raw } // renderMarkdown converts raw markdown to trusted HTML. func renderMarkdown(raw []byte) template.HTML { var buf bytes.Buffer if err := md.Convert(raw, &buf); err != nil { return "" } return template.HTML(buf.String()) } // parentURL returns the parent URL of a slash-terminated URL path. func parentURL(urlPath string) string { parent := path.Dir(strings.TrimSuffix(urlPath, "/")) if parent == "." || parent == "/" { return "/" } return parent + "/" } // renderDiaryYear renders month sections with photo counts for a year folder. func (h *handler) renderDiaryYear(fsPath, urlPath string) template.HTML { yearStr := filepath.Base(fsPath) year, err := strconv.Atoi(yearStr) if err != nil { return "" } photos := yearPhotos(fsPath, urlPath) entries, err := os.ReadDir(fsPath) if err != nil { return "" } var months []diaryMonthSummary for _, e := range entries { if !e.IsDir() { continue } monthNum, err := strconv.Atoi(e.Name()) if err != nil || monthNum < 1 || monthNum > 12 { continue } count := 0 for _, p := range photos { if p.Date.Year() == year && int(p.Date.Month()) == monthNum { count++ } } monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC) months = append(months, diaryMonthSummary{ Name: monthDate.Format("January 2006"), URL: path.Join(urlPath, e.Name()) + "/", PhotoCount: count, }) } var buf bytes.Buffer if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months}); err != nil { log.Printf("diary year template: %v", err) return "" } return template.HTML(buf.String()) } // renderDiaryMonth renders a section per day, each with its markdown content // and photos sourced from the parent year folder. func (h *handler) renderDiaryMonth(fsPath, urlPath string) template.HTML { yearFsPath := filepath.Dir(fsPath) yearURLPath := parentURL(urlPath) year, err := strconv.Atoi(filepath.Base(yearFsPath)) if err != nil { return "" } monthNum, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil || monthNum < 1 || monthNum > 12 { return "" } allPhotos := yearPhotos(yearFsPath, yearURLPath) var monthPhotos []diaryPhoto for _, p := range allPhotos { if p.Date.Year() == year && int(p.Date.Month()) == monthNum { monthPhotos = append(monthPhotos, p) } } // Collect day numbers from subdirectories and from photo filenames. daySet := map[int]bool{} dayDirs := map[int]string{} // day number โ†’ actual directory name entries, _ := os.ReadDir(fsPath) for _, e := range entries { if !e.IsDir() { continue } d, err := strconv.Atoi(e.Name()) if err != nil || d < 1 || d > 31 { continue } daySet[d] = true dayDirs[d] = e.Name() } for _, p := range monthPhotos { daySet[p.Date.Day()] = true } days := make([]int, 0, len(daySet)) for d := range daySet { days = append(days, d) } sort.Ints(days) var sections []diaryDaySection for _, dayNum := range days { date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC) heading := date.Format("Monday, January 2") dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/" var content template.HTML if dirName, ok := dayDirs[dayNum]; ok { dayURL = path.Join(urlPath, dirName) + "/" dayFsPath := filepath.Join(fsPath, dirName) if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 { if h := extractFirstHeading(raw); h != "" { heading = h raw = stripFirstHeading(raw) } content = renderMarkdown(raw) } } var photos []diaryPhoto for _, p := range monthPhotos { if p.Date.Day() == dayNum { photos = append(photos, p) } } sections = append(sections, diaryDaySection{ Heading: heading, URL: dayURL, Content: content, Photos: photos, }) } var buf bytes.Buffer if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil { log.Printf("diary month template: %v", err) return "" } return template.HTML(buf.String()) } // renderDiaryDay renders the photo grid for a single day, sourcing photos // from the grandparent year folder. func (h *handler) renderDiaryDay(fsPath, urlPath string) template.HTML { monthFsPath := filepath.Dir(fsPath) yearFsPath := filepath.Dir(monthFsPath) yearURLPath := parentURL(parentURL(urlPath)) year, err := strconv.Atoi(filepath.Base(yearFsPath)) if err != nil { return "" } monthNum, err := strconv.Atoi(filepath.Base(monthFsPath)) if err != nil { return "" } dayNum, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil { return "" } allPhotos := yearPhotos(yearFsPath, yearURLPath) var photos []diaryPhoto for _, p := range allPhotos { if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum { photos = append(photos, p) } } if len(photos) == 0 { return "" } var buf bytes.Buffer if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil { log.Printf("diary day template: %v", err) return "" } return template.HTML(buf.String()) } 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] }