package web import ( "donniemarko/internal/note" "donniemarko/internal/render" "donniemarko/internal/service" "html/template" "log" "net/http" "net/url" "path/filepath" "sort" "strings" ) type Handler struct { notesService *service.NotesService templates *render.TemplateManager mux *http.ServeMux } func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler { return &Handler{ notesService: ns, templates: tm, mux: http.NewServeMux(), } } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path log.Printf("request: method=%s path=%s host=%s remote=%s", r.Method, path, r.Host, r.RemoteAddr) // Handle root and note list if path == "/" { h.handleRoot(w, r) return } // Handle individual notes if strings.HasPrefix(path, "/notes/") { if strings.Contains(path, "/tags") { h.handleTags(w, r) return } if strings.Contains(path, "/sections/") { h.handleSections(w, r) return } h.handleNotes(w, r) return } // Handle 404 for other paths log.Printf("not found: method=%s path=%s host=%s remote=%s", r.Method, path, r.Host, r.RemoteAddr) http.NotFound(w, r) } // ViewState is built per-request, not shared type ViewState struct { Notes []*note.Note Groups []NoteGroup Note *note.Note RenderedNote template.HTML SortBy string SearchTerm string TagFilter string LastActive string // BasePath is an optional URL prefix (e.g. "/donnie") when served behind a reverse proxy. BasePath string } type NoteGroup struct { Name string Notes []*note.Note } func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) { // Build view state from query params state, err := h.buildViewState(r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } active := r.URL.Query().Get("active") if active != "" { _ = h.setActiveNote(state, active) } // Render with state if err := h.templates.Render(w, "index", state); err != nil { log.Printf("render error: %v", err) http.Error(w, "Render error", http.StatusInternalServerError) } } func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) { query := r.URL.Query() // Extract params sortBy := query.Get("sort") if sortBy == "" { sortBy = "recent" } searchTerm := query.Get("search") tagFilter := query.Get("tag") active := query.Get("active") // Preserve proxy prefix so templates can build correct links and asset URLs. basePath := basePathFromRequest(r) // Get notes from service var notes []*note.Note var err error if searchTerm != "" { opts := service.QueryOptions{ SearchTerm: searchTerm, SortBy: sortBy, } notes, err = h.notesService.QueryNotes(opts) if err != nil { return nil, err } } else { notes = h.notesService.GetNotes() // Apply sorting switch sortBy { case "recent": service.SortByDate(notes) case "oldest": service.SortByDateAsc(notes) case "alpha": service.SortByTitle(notes) case "ralpha": service.SortByTitleAsc(notes) default: service.SortByDate(notes) } } if tagFilter != "" { notes = filterNotesByTag(notes, tagFilter) } return &ViewState{ Notes: notes, Groups: groupNotesByFolder(notes), SortBy: sortBy, SearchTerm: searchTerm, TagFilter: tagFilter, LastActive: active, BasePath: basePath, }, nil } func (h *Handler) SetupRoutes() { // Set the handler as the main handler for http.DefaultServeMux http.Handle("/", h) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(StaticFS())))) } func extractHash(path string) string { // Extract hash from /notes/{hash} parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) < 2 || parts[0] != "notes" { return "" } return parts[1] } func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) { // Build base state state, err := h.buildViewState(r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Extract hash from URL hash := extractHash(r.URL.Path) // Get specific note note, err := h.notesService.GetNoteByHash(hash) if err != nil { http.Error(w, "Note not found", http.StatusNotFound) return } // Convert markdown to HTML, linking section headings basePath := basePathFromRequest(r) htmlContent, err := renderNoteMarkdown(note.Content, note.ID, basePath) if err != nil { http.Error(w, "Failed to render markdown", http.StatusInternalServerError) return } // Add to state state.Note = note state.RenderedNote = htmlContent state.LastActive = hash // Ensure note view carries proxy prefix for links/forms. state.BasePath = basePath if err := h.templates.Render(w, "index", state); err != nil { log.Printf("render error: %v", err) http.Error(w, "Render error", http.StatusInternalServerError) } } func (h *Handler) setActiveNote(state *ViewState, noteID string) error { note, err := h.notesService.GetNoteByHash(noteID) if err != nil { return err } htmlContent, err := renderNoteMarkdown(note.Content, note.ID, state.BasePath) if err != nil { return err } state.Note = note state.RenderedNote = htmlContent state.LastActive = noteID return nil } func filterNotesByTag(notes []*note.Note, tag string) []*note.Note { tag = strings.ToLower(strings.TrimSpace(tag)) if tag == "" { return notes } filtered := make([]*note.Note, 0, len(notes)) for _, n := range notes { for _, t := range n.Tags { if strings.EqualFold(t, tag) { filtered = append(filtered, n) break } } } return filtered } func groupNotesByFolder(notes []*note.Note) []NoteGroup { groups := make(map[string][]*note.Note) for _, n := range notes { name := rootFolderName(n.Path) groups[name] = append(groups[name], n) } if len(groups) == 0 { return nil } names := make([]string, 0, len(groups)) for name := range groups { names = append(names, name) } sort.Strings(names) result := make([]NoteGroup, 0, len(names)) for _, name := range names { result = append(result, NoteGroup{ Name: name, Notes: groups[name], }) } return result } func rootFolderName(path string) string { if path == "" { return "root" } clean := filepath.ToSlash(filepath.Clean(path)) if clean == "." || clean == "/" { return "root" } clean = strings.TrimPrefix(clean, "/") parts := strings.Split(clean, "/") if len(parts) == 0 || parts[0] == "." { return "root" } if len(parts) == 1 { return "root" } return parts[0] } func basePathFromRequest(r *http.Request) string { // X-Forwarded-Prefix is commonly set by reverse proxies for subpath mounts. prefix := strings.TrimSpace(r.Header.Get("X-Forwarded-Prefix")) if prefix == "" { return "" } if !strings.HasPrefix(prefix, "/") { prefix = "/" + prefix } return strings.TrimRight(prefix, "/") } func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } noteID, tag, isRemove := parseTagRoute(r.URL.Path) if noteID == "" { http.NotFound(w, r) return } if isRemove { if tag == "" { http.Error(w, "Missing tag", http.StatusBadRequest) return } if err := h.notesService.RemoveTag(noteID, tag); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } else { if err := r.ParseForm(); err != nil { http.Error(w, "Invalid form", http.StatusBadRequest) return } tag := r.FormValue("tag") if tag == "" { http.Error(w, "Missing tag", http.StatusBadRequest) return } if err := h.notesService.AddTag(noteID, tag); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } // Redirect using proxy prefix when present. basePath := basePathFromRequest(r) http.Redirect(w, r, basePath+"/notes/"+noteID, http.StatusSeeOther) } func parseTagRoute(path string) (noteID string, tag string, isRemove bool) { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) < 3 || parts[0] != "notes" || parts[2] != "tags" { return "", "", false } noteID = parts[1] if len(parts) >= 4 { decoded, err := url.PathUnescape(parts[3]) if err != nil { return noteID, "", true } return noteID, decoded, true } return noteID, "", false } func parseSectionRoute(path string) (noteID string, sectionID string) { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) < 4 || parts[0] != "notes" || parts[2] != "sections" { return "", "" } return parts[1], parts[3] } func (h *Handler) handleSections(w http.ResponseWriter, r *http.Request) { // Build base state state, err := h.buildViewState(r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } noteID, sectionID := parseSectionRoute(r.URL.Path) if noteID == "" || sectionID == "" { http.NotFound(w, r) return } n, err := h.notesService.GetNoteByHash(noteID) if err != nil { http.Error(w, "Note not found", http.StatusNotFound) return } var sectionContent string for _, section := range note.ParseSections(n.Content) { if section.ID == sectionID { sectionContent = section.Content break } } if sectionContent == "" { http.Error(w, "Section not found", http.StatusNotFound) return } basePath := basePathFromRequest(r) htmlContent, err := render.RenderMarkdown([]byte(sectionContent)) if err != nil { http.Error(w, "Failed to render markdown", http.StatusInternalServerError) return } state.Note = n state.RenderedNote = htmlContent state.LastActive = noteID state.BasePath = basePath if err := h.templates.Render(w, "index", state); err != nil { log.Printf("render error: %v", err) http.Error(w, "Render error", http.StatusInternalServerError) } } func renderNoteMarkdown(content, noteID, basePath string) (template.HTML, error) { linked := linkifySectionsMarkdown(content, noteID, basePath) return render.RenderMarkdown([]byte(linked)) } func linkifySectionsMarkdown(content, noteID, basePath string) string { sections := note.ParseSections(content) if len(sections) == 0 { return content } lines := strings.Split(content, "\n") sectionIdx := 0 for i, line := range lines { heading, ok := note.ParseH2Heading(line) if !ok { continue } if sectionIdx >= len(sections) { break } link := basePath + "/notes/" + noteID + "/sections/" + sections[sectionIdx].ID lines[i] = "## [" + heading + "](" + link + ")" sectionIdx++ } return strings.Join(lines, "\n") }