package main import ( "context" "embed" "flag" "html/template" "log" "net/http" "net/url" "os" "path" "path/filepath" "strconv" "strings" "time" _ "time/tzdata" // calendar TZIDs must resolve even without system zoneinfo ) //go:embed assets var assets embed.FS // tmplFuncs is shared by every layout-based template: asset links versioned // static files (see static.go); the layout header renders the maintenance // widget. var tmplFuncs = template.FuncMap{ "asset": assetURL, "fileIcon": fileIcon, "icon": readIcon, "maintenanceWidget": maintenanceWidget, } var ( pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html")) editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html")) searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html")) ) // specialPage is the result returned by a pageTypeHandler. // Content is injected into the page after the standard markdown content. // SuppressContent hides the markdown-rendered content (handler owns rendering). // SuppressListing hides the default file/folder listing. // Widget is a persistent sidebar widget rendered outside the main content area. type specialPage struct { Content template.HTML SuppressContent bool SuppressListing bool SuppressTOC bool Widget template.HTML } // pageTypeHandler is implemented by each special folder type (diary, gallery, …). // handle returns nil when the handler does not apply to the given path. The // request is passed read-only (e.g. query params selecting a view variant); // mutations belong in the POST flow, not here. // redirect returns ok=true with an absolute URL when the request should be // short-circuited with a 302 redirect (e.g. persistent date links in a diary, // or virtual diary URLs in edit mode that delegate to the year file's editor). // // When adding a new hook, prefer a sibling method here over folding logic // into main.go or render.go. type pageTypeHandler interface { handle(root, fsPath, urlPath string, r *http.Request) *specialPage redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool) } // 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") cacheDir := flag.String("cache", "./cache", "thumbnail cache directory") user := flag.String("user", "", "basic auth username (empty = no auth)") pass := flag.String("pass", "", "basic auth password") reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)") calendars := flag.String("calendars", "Calendars", "calendars folder, relative to -dir (empty disables)") tz := flag.String("tz", "", "time zone for calendars and the diary, e.g. Europe/Berlin (default: system)") flag.Parse() if *tz != "" { loc, err := time.LoadLocation(*tz) if err != nil { log.Fatalf("-tz: %v", err) } time.Local = loc } root, err := filepath.Abs(*wikiDir) if err != nil { log.Fatal(err) } if err := os.MkdirAll(root, 0755); err != nil { log.Fatal(err) } thumbCacheDir, err = filepath.Abs(*cacheDir) if err != nil { log.Fatal(err) } if err := os.MkdirAll(thumbCacheDir, 0755); err != nil { log.Fatal(err) } initMarkdown() if *calendars != "" { calendarsDir = filepath.Join(root, filepath.FromSlash(*calendars)) rel, err := filepath.Rel(root, calendarsDir) if err != nil || rel == "." || strings.HasPrefix(rel, "..") { log.Fatalf("-calendars must be a folder inside -dir") } calendarsURL = "/" + filepath.ToSlash(rel) + "/" } authKey, err := loadOrCreateAuthKey(root) if err != nil { log.Fatal(err) } h := &handler{root: root, user: *user, pass: *pass, authKey: authKey} http.Handle("/_/", serveStatic()) http.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_reindex", h.handleReindex) http.HandleFunc("/_search", h.handleSearchSuggest) http.HandleFunc("/quickadd", h.handleQuickAdd) http.Handle("/", h) // Build the folder index off the request path so the listener can start // accepting connections immediately. searchWiki blocks on folderIndex.ready // so the first search after a cold start still returns correct results. go func() { folderIndex.buildMu.Lock() folders, files := buildIndexes(root) now := time.Now() folderIndex.Lock() folderIndex.entries = folders folderIndex.builtAt = now folderIndex.Unlock() fileIndex.Lock() fileIndex.entries = files fileIndex.builtAt = now fileIndex.Unlock() folderIndex.buildMu.Unlock() close(folderIndex.ready) close(fileIndex.ready) }() if *reindexInterval > 0 { go func(interval time.Duration) { t := time.NewTicker(interval) defer t.Stop() for range t.C { rebuildFolderIndex(root) } }(*reindexInterval) } if calendarsDir != "" { startCalendarSync() } log.Printf("datascape listening on %s, wiki at %s", *addr, root) log.Fatal(http.ListenAndServe(*addr, nil)) } type handler struct { root, user, pass string authKey []byte } // reqStartKey marks the request start time stored in the request context // so HTML templates can render total server-side processing time. type reqStartKeyT struct{} var reqStartKey = reqStartKeyT{} // requestStart returns when the request entered ServeHTTP, or now if unknown. func requestStart(r *http.Request) time.Time { if start, ok := r.Context().Value(reqStartKey).(time.Time); ok { return start } return time.Now() } // renderTimer is embedded in template data. The footer calls RenderMS while // the template executes, so the time includes the page's own rendering (and // the header's maintenance widget), not just the work before it. type renderTimer struct{ start time.Time } func (t renderTimer) RenderMS() int64 { return time.Since(t.start).Milliseconds() } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now())) if !h.checkAuth(w, r) { return } // Dotfiles are never served: .page-settings can hold the CalDAV // password and .auth-key signs sessions. Nothing links to them (listing, // tree and search hide them already). if hasDotSegment(r.URL.Path) { http.NotFound(w, r) return } if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") { h.handleThumb(w, r) 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 } if r.Method == http.MethodGet && r.URL.Query().Has("tree") { h.handleTree(w, r, urlPath, fsPath) return } if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") { h.handleSearch(w, r) return } info, err := os.Stat(fsPath) if err != nil { if os.IsNotExist(err) { // Non-existent path: redirect GETs to the canonical slash form so // the browser URL is consistent, then serve an empty folder page. // POSTs must not be redirected — the form action has no trailing // slash (path.Clean strips it) and the content would be lost. if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost { http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently) return } h.serveDir(w, r, urlPath, fsPath) return } http.NotFound(w, r) return } if info.IsDir() { if urlPath != "/" { // Pages must live at a slash-terminated URL: embed destinations are // relative, so "/Trips/Japan" would resolve them against "/Trips/". if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost { http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently) return } urlPath += "/" } h.serveDir(w, r, urlPath, fsPath) } else { // ?w= on a file's own URL serves a thumbnail. Embeds use this form so // their can stay relative to the page; /_thumb/ // stays for listings, which have absolute paths on hand anyway. A file // with no thumbnailer ignores the parameter and serves normally. if r.URL.Query().Has("w") && hasThumbnail(fsPath) { h.serveThumbnail(w, r, fsPath) return } http.ServeFile(w, r, fsPath) } } // hasDotSegment reports whether any segment of the cleaned URL path starts // with a dot. func hasDotSegment(p string) bool { for _, seg := range strings.Split(path.Clean("/"+p), "/") { if strings.HasPrefix(seg, ".") { return true } } return false } // slashedURL returns u's path with a trailing slash appended and the query // string intact — the canonical page form that relative destinations resolve // against. func slashedURL(u *url.URL) string { target := u.Path + "/" if u.RawQuery != "" { target += "?" + u.RawQuery } return target } 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 } if r.URL.Query().Has("dialog") { h.serveDialog(w, r, urlPath) return } for _, ph := range pageTypeHandlers { if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok { http.Redirect(w, r, target, http.StatusFound) return } } indexPath := filepath.Join(fsPath, "index.md") rawMD, _ := os.ReadFile(indexPath) // Determine section index (-1 = whole page). sectionIndex := -1 insertBefore := -1 sectionSpan := false if editMode { if s := r.URL.Query().Get("section"); s != "" { if n, err := strconv.Atoi(s); err == nil && n >= 0 { sectionIndex = n } } if s := r.URL.Query().Get("insert_before"); s != "" { if n, err := strconv.Atoi(s); err == nil && n >= 0 { insertBefore = n } } sectionSpan = r.URL.Query().Has("span") } var special *specialPage if !editMode { for _, ph := range pageTypeHandlers { if special = ph.handle(h.root, fsPath, urlPath, r); special != nil { break } } } var rendered template.HTML if len(rawMD) > 0 && !editMode && (special == nil || !special.SuppressContent) { rendered = renderMarkdown(rawMD) } view, sortKey, order := readPageSettings(fsPath).viewSettings() var entries []entry if !editMode && (special == nil || !special.SuppressListing) { entries = listEntries(fsPath, urlPath, sortKey, order) } title := pageTitle(urlPath) if heading := extractFirstHeading(rawMD); heading != "" { title = heading } var specialContent template.HTML var sidebarWidget template.HTML suppressTOC := false if special != nil { specialContent = special.Content sidebarWidget = special.Widget suppressTOC = special.SuppressTOC } rawContent := string(rawMD) if editMode && insertBefore >= 0 { heading := r.URL.Query().Get("heading") level := r.URL.Query().Get("level") if level == "" { level = "###" } if heading != "" { rawContent = level + " " + heading + "\n\n" } else { rawContent = "" } } else if editMode && sectionIndex >= 0 { sections := splitSections(rawMD) if sectionIndex < len(sections) { end := sectionIndex + 1 if sectionSpan { end = secionSpanEnd(sections, sectionIndex) } rawContent = string(joinSections(sections[sectionIndex:end])) } } else if editMode && rawContent == "" && urlPath != "/" { rawContent = "# " + pageTitle(urlPath) + "\n\n" } data := pageData{ Title: title, CanEdit: true, EditMode: editMode, IsRoot: urlPath == "/", SectionIndex: sectionIndex, InsertBefore: insertBefore, Span: sectionSpan, PostURL: urlPath, RawContent: rawContent, Content: rendered, Entries: entries, View: view, Sort: sortKey, Order: order, SpecialContent: specialContent, SidebarWidget: sidebarWidget, SuppressTOC: suppressTOC, } w.Header().Set("Content-Type", "text/html; charset=utf-8") t := pageTmpl if editMode { t = editTmpl } data.renderTimer = renderTimer{requestStart(r)} if err := t.ExecuteTemplate(w, "layout", data); err != nil { log.Printf("template error: %v", err) } } func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { query := r.URL.Query() if query.Has("delete") { h.handleDelete(w, r, urlPath, fsPath) return } if _, ok := query["move"]; ok { // The destination comes either whole (?move=/dst, or the merge // dialog's move field) or as the move dialog's parent + name. // Body fields take precedence over the bare ?move query key. if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } dst := r.FormValue("move") if name := strings.TrimSpace(r.FormValue("name")); dst == "" && name != "" { dst = strings.TrimRight(r.FormValue("parent"), "/") + "/" + name } h.handleMove(w, r, urlPath, fsPath, dst, r.FormValue("merge") != "") return } if query.Has("toggle") { h.handleToggle(w, r, fsPath) return } if query.Has("append") { h.handleAppend(w, r, urlPath, fsPath) return } if query.Has("settings") { h.handleSettings(w, r, urlPath, fsPath) return } if query.Has("calsync") { h.handleCalendarSync(w, r, urlPath, fsPath) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } content := r.FormValue("content") indexPath := filepath.Join(fsPath, "index.md") redirectTarget := urlPath // handle section saving if s := r.FormValue("insert_before"); s != "" { insertIndex, err := strconv.Atoi(s) if err != nil || insertIndex < 0 { http.Error(w, "bad insert_before", http.StatusBadRequest) return } rawMD, _ := os.ReadFile(indexPath) sections := splitSections(rawMD) if insertIndex > len(sections) { insertIndex = len(sections) } newSection := []byte(content) inserted := make([][]byte, 0, len(sections)+1) inserted = append(inserted, sections[:insertIndex]...) inserted = append(inserted, newSection) inserted = append(inserted, sections[insertIndex:]...) content = string(joinSections(inserted)) ids := headingIDs([]byte(content)) if insertIndex-1 >= 0 && insertIndex-1 < len(ids) { redirectTarget = urlPath + "#" + ids[insertIndex-1] } } else if s := r.FormValue("section"); s != "" { sectionIndex, err := strconv.Atoi(s) if err != nil || sectionIndex < 0 { http.Error(w, "bad section", http.StatusBadRequest) return } rawMD, _ := os.ReadFile(indexPath) sections := splitSections(rawMD) // Out of range means the file changed under the editor (or the index // never matched it). Writing back the untouched file would swallow the // edit silently, so refuse and keep the editor's content in the browser. if sectionIndex >= len(sections) { http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict) return } // handling of section editing that spans until the next heading of the same level end := sectionIndex + 1 if r.FormValue("span") != "" { end = secionSpanEnd(sections, sectionIndex) } merged := make([][]byte, 0, len(sections)-(end-sectionIndex)+1) merged = append(merged, sections[:sectionIndex]...) merged = append(merged, []byte(content)) merged = append(merged, sections[end:]...) content = string(joinSections(merged)) // Section index ≥ 1 is a heading-anchored section. Redirect to its // anchor so the user lands on the section they just saved, even if // the heading text changed. if sectionIndex >= 1 { ids := headingIDs([]byte(content)) if sectionIndex-1 < len(ids) { redirectTarget = urlPath + "#" + ids[sectionIndex-1] } } } // A save must never remove a page. An empty POST — a truncated mobile // request, a lost `section` field, an editor that came up blank — is // indistinguishable from "clear this page", and deleting index.md on that // signal loses the whole file even though the user only edited one section. // Removing a page is the explicit ?delete action's job (moves.go). if strings.TrimSpace(content) == "" { http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest) return } // Stat first so we know whether MkdirAll actually created the folder // — if it did, the search index needs a new entry. _, statErr := os.Stat(fsPath) newlyCreated := os.IsNotExist(statErr) 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 } if newlyCreated { if rel, err := filepath.Rel(h.root, fsPath); err == nil { folderIndexAdd(filepath.ToSlash(rel)) } } // The editor saves via fetch so the save and its result share one history // entry (see assets/history-nav.js). Hand it the target instead of a 303: // the browser would follow the redirect into a second entry, and fetch // drops the #section fragment from a followed redirect anyway. if r.Header.Get("X-Save-Mode") == "replace" { w.Header().Set("X-Target", redirectTarget) w.WriteHeader(http.StatusNoContent) return } http.Redirect(w, r, redirectTarget, 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 { kv := readSettingsFile(dir) if kv == nil { return nil } // Unknown or missing values fall back to the defaults. return &pageSettings{ Type: kv["type"], View: validateView(kv["view"]), Sort: validateSort(kv["sort"]), Order: validateOrder(kv["order"]), } } // readSettingsFile returns every key of dir's .page-settings (last one wins), // or nil if the file does not exist. Other features (e.g. the calendars // folder) keep their own keys in the same file. func readSettingsFile(dir string) map[string]string { data, err := os.ReadFile(filepath.Join(dir, ".page-settings")) if err != nil { return nil } kv := map[string]string{} 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 } kv[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } return kv }