commit 533ac4e58256e6520a86af964fcf4c2f9a98d4ba Author: adminoo <git@kadath.corp> Date: Mon Feb 23 18:52:59 2026 +0100 feat: freebsd release tarball generator commit874fb63fd0Author: adminoo <git@kadath.corp> Date: Mon Feb 23 14:05:24 2026 +0100 feat: bump changelog commit46ab7e2911Author: adminoo <git@kadath.corp> Date: Mon Feb 23 13:58:14 2026 +0100 feat: margin and page breaks commit44751a808aAuthor: adminoo <git@kadath.corp> Date: Mon Feb 23 13:57:56 2026 +0100 feat: picture are worth thousand words commita5683428e0Author: adminoo <git@kadath.corp> Date: Mon Feb 23 13:39:00 2026 +0100 feat: navigate individual sections commit0d9b7c4e7bAuthor: adminoo <git@kadath.corp> Date: Mon Feb 23 13:38:19 2026 +0100 feat: make use of vendoring
454 lines
10 KiB
Go
454 lines
10 KiB
Go
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")
|
|
}
|