commit a1b9ac0dffdcf245767314f4d733cd2105434e3a Author: Florian Walther Date: Fri May 1 18:38:29 2026 +0200 first commit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c1ace75 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module mdhost + +go 1.26.2 + +require ( + github.com/alecthomas/chroma v0.10.0 // indirect + github.com/alecthomas/chroma/v2 v2.24.1 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 // indirect + golang.org/x/crypto v0.50.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..13779f8 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= +github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= +github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg= +github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..91aadd1 --- /dev/null +++ b/main.go @@ -0,0 +1,1039 @@ +package main + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark-highlighting" + "golang.org/x/crypto/bcrypt" +) + +var ( + postsDir = "./posts" + dataDir = "./data" + port = ":8080" + markdown = newMarkdownRenderer() + dataMu sync.RWMutex +) + +// ListFormat configures how files are rendered in the index +var ListFormat = "ul" // Options: "ul" (bullet list), "ol" (numbered), "plain" (colon-separated) + +// Cookie settings +var ( + cookieName = "mdhost_session" + cookiePath = "/" + cookieMaxAge = 365 * 24 * 60 * 60 // 1 year +) + +// User represents an authenticated user +type User struct { + ID string + Username string + Password string // bcrypt hash +} + +// Session represents a user session (anonymous or authenticated) +type Session struct { + ID string + UserID string // empty if anonymous + Expires time.Time + Posts []string // file IDs belonging to this session +} + +// FileStore holds file metadata with unique IDs +type FileStore struct { + mu sync.RWMutex + files map[string]FileEntry // id -> entry + byName map[string][]string // original name -> list of ids + firstSeen map[string]int64 // fullName -> first seen timestamp (creation proxy) +} + +type FileEntry struct { + ID string + Name string // original filename without extension + FullName string // full filename with extension + SessionID string // session that created this file + UserID string // user who owns this file (empty if unclaimed) + IsPublic bool // true = listed in public list, false = only in "My Files" + CreatedAt int64 // Unix nanoseconds - timestamp for stable ID generation +} + +var ( + store = &FileStore{ + files: make(map[string]FileEntry), + byName: make(map[string][]string), + firstSeen: make(map[string]int64), + } + users = make(map[string]User) // username -> User + sessions = make(map[string]Session) // session_id -> Session +) + +func newMarkdownRenderer() goldmark.Markdown { + return goldmark.New( + goldmark.WithExtensions(highlighting.NewHighlighting( + highlighting.WithStyle("github"), + )), + ) +} + +func homeHandler(w http.ResponseWriter, r *http.Request) { + renderPage(w, r, "mdhost - Home", homeContent) +} + +// PersistentData holds all data that needs to survive restarts +type PersistentData struct { + Users map[string]User `json:"users"` + Sessions map[string]Session `json:"sessions"` + Files map[string]FileEntry `json:"files"` +} + +// saveData saves all persistent data to disk. Caller must hold dataMu lock. +func saveData() error { + store.mu.RLock() + defer store.mu.RUnlock() + + if err := os.MkdirAll(dataDir, 0755); err != nil { + return err + } + + data := PersistentData{ + Users: users, + Sessions: sessions, + Files: store.files, + } + + file, err := os.Create(filepath.Join(dataDir, "data.json")) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(data) +} + +// loadData loads persistent data from disk. Caller must hold dataMu lock. +func loadData() error { + file, err := os.Open(filepath.Join(dataDir, "data.json")) + if err != nil { + // File doesn't exist yet - that's fine + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + + var data PersistentData + decoder := json.NewDecoder(file) + if err := decoder.Decode(&data); err != nil { + return err + } + + // Restore data + users = data.Users + sessions = data.Sessions + store.mu.Lock() + for id, entry := range data.Files { + // Migration: IsPublic field was added later. Old files won't have it in JSON, + // so they'll be unmarshaled with IsPublic=false (zero value). + // For this one-time migration, make all existing files public. + // Users can mark them private afterward if desired. + entry.IsPublic = true + store.files[id] = entry + // Rebuild byName index + if _, exists := store.byName[entry.Name]; !exists { + store.byName[entry.Name] = []string{} + } + store.byName[entry.Name] = append(store.byName[entry.Name], id) + // Restore firstSeen from CreatedAt + if entry.CreatedAt != 0 { + store.firstSeen[entry.FullName] = entry.CreatedAt + } else { + // Fallback for very old entries without CreatedAt + store.firstSeen[entry.FullName] = time.Now().UnixNano() + } + } + store.mu.Unlock() + + return nil +} + +func getNavBar(r *http.Request) string { + if isAuthenticated(r) { + username := "" + userID := getCurrentUserID(r) + for _, user := range users { + if user.ID == userID { + username = user.Username + break + } + } + return fmt.Sprintf(``, username) + } + return `` +} + +// Session helpers - all access sessions map under dataMu lock +func getOrCreateSession(w http.ResponseWriter, r *http.Request) string { + dataMu.Lock() + defer dataMu.Unlock() + + // Check existing cookie + cookie, err := r.Cookie(cookieName) + if err == nil && cookie.Value != "" { + if session, exists := sessions[cookie.Value]; exists { + if time.Now().Before(session.Expires) { + // Extend session + sessions[cookie.Value] = Session{ + ID: cookie.Value, + UserID: session.UserID, + Expires: time.Now().Add(time.Duration(cookieMaxAge) * time.Second), + Posts: session.Posts, + } + // Refresh cookie + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: cookie.Value, + Expires: time.Now().Add(time.Duration(cookieMaxAge) * time.Second), + Path: cookiePath, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + return cookie.Value + } + // Expired - delete cookie and create new + cookie.Expires = time.Unix(0, 0) + http.SetCookie(w, cookie) + delete(sessions, cookie.Value) + } + } + + // Create new session + sessionID := generateRandomID(32) + sessions[sessionID] = Session{ + ID: sessionID, + UserID: "", + Expires: time.Now().Add(time.Duration(cookieMaxAge) * time.Second), + Posts: []string{}, + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: sessionID, + Expires: time.Now().Add(time.Duration(cookieMaxAge) * time.Second), + Path: cookiePath, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + return sessionID +} + +func getSession(r *http.Request) *Session { + dataMu.RLock() + defer dataMu.RUnlock() + + cookie, err := r.Cookie(cookieName) + if err != nil { + return nil + } + if session, exists := sessions[cookie.Value]; exists { + return &session + } + return nil +} + +func getCurrentUserID(r *http.Request) string { + session := getSession(r) + if session == nil { + return "" + } + return session.UserID +} + +func isAuthenticated(r *http.Request) bool { + return getCurrentUserID(r) != "" +} + +// generateRandomID creates a random hex string of given length +func generateRandomID(length int) string { + b := make([]byte, length/2) + if _, err := rand.Read(b); err != nil { + // Fallback to timestamp-based ID + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b)[:length] +} + +const homeContent = `

Welcome to mdhost

+ +

mdhost is a simple Go web application for hosting and sharing markdown files.

+ +

Features

+ + + +

Quick Start

+ +
    +
  1. List files: Click "list" to see all markdown files
  2. +
  3. Add a file: Click "add" to create a new markdown file
  4. +
  5. Edit a file: Click "edit" to modify existing files, or use the [edit] link next to each file in the list
  6. +
  7. View a file: Click any file name in the list to view its rendered content
  8. +
+ +

Markdown Tips

+ +
# Heading 1
+## Heading 2
+
+- List item
+- Another item
+
+code block
+
+```bash
+# Bash code with syntax highlighting
+echo "Hello World"
+```
+
+[Link](https://example.com)
+ +

Enjoy using mdhost!

` + +// scanPosts scans the posts directory and builds the file store +func scanPosts() { + store.mu.Lock() + defer store.mu.Unlock() + + newFiles := make(map[string]FileEntry) + newByName := make(map[string][]string) + + files, err := os.ReadDir(postsDir) + if err != nil { + log.Printf("Error reading posts directory: %v", err) + return + } + + for _, f := range files { + if f.IsDir() || !strings.HasSuffix(f.Name(), ".md") { + continue + } + + fullName := f.Name() + name := strings.TrimSuffix(fullName, ".md") + info, _ := f.Info() + + // Try to find existing entry by filename + var sessionID, userID string + var isPublic bool = true + var createdAt int64 + var existingID string + found := false + + for id, entry := range store.files { + if entry.FullName == fullName { + // File already in store - preserve all metadata + sessionID = entry.SessionID + userID = entry.UserID + isPublic = entry.IsPublic + createdAt = entry.CreatedAt + existingID = id + found = true + break + } + } + + if !found { + // New file - use file modification time + createdAt = info.ModTime().UnixNano() + } + + // Generate unique ID: hash of filename + creation timestamp + id := generateID(fullName, createdAt) + if existingID != "" { + id = existingID // Use existing ID if file was already in store + } + + entry := FileEntry{ + ID: id, + Name: name, + FullName: fullName, + SessionID: sessionID, + UserID: userID, + IsPublic: isPublic, + CreatedAt: createdAt, + } + + newFiles[id] = entry + newByName[name] = append(newByName[name], id) + store.firstSeen[fullName] = createdAt + } + + store.files = newFiles + store.byName = newByName +} + +// generateID creates a short unique ID from name and timestamp +func generateID(name string, timestamp int64) string { + h := sha1.New() + io.WriteString(h, name) + io.WriteString(h, fmt.Sprintf("%d", timestamp)) + return hex.EncodeToString(h.Sum(nil))[:12] +} + +func main() { + if err := os.MkdirAll(postsDir, 0755); err != nil { + log.Fatal(err) + } + + // Load persistent data (locks internally via file read, but we need to protect our maps) + dataMu.Lock() + if err := loadData(); err != nil { + log.Printf("Warning: failed to load data: %v", err) + } + dataMu.Unlock() + + // Initial scan + scanPosts() + + // Periodically rescan for new files and save data + go func() { + for { + <-time.After(10 * time.Second) + scanPosts() + dataMu.Lock() + if err := saveData(); err != nil { + log.Printf("Warning: failed to save data: %v", err) + } + dataMu.Unlock() + } + }() + + http.HandleFunc("/home", homeHandler) + http.HandleFunc("/", listHandler) + http.HandleFunc("/view/", viewHandler) + http.HandleFunc("/add", addHandler) + http.HandleFunc("/edit", editHandler) + http.HandleFunc("/create", createHandler) + http.HandleFunc("/update", updateHandler) + http.HandleFunc("/delete", deleteHandler) + http.HandleFunc("/register", registerHandler) + http.HandleFunc("/login", loginHandler) + http.HandleFunc("/logout", logoutHandler) + http.HandleFunc("/claim", claimHandler) + + fmt.Printf("Server running on http://localhost%s\n", port) + fmt.Printf("Markdown files served from %s\n", postsDir) + log.Fatal(http.ListenAndServe(port, nil)) +} + +const css = `` + +const chromaCSS = `` + +func renderPage(w http.ResponseWriter, r *http.Request, title, body string) { + fmt.Fprintf(w, "%s%s%s%s
%s
", title, css, chromaCSS, getNavBar(r), body) +} + +func listHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + store.mu.RLock() + defer store.mu.RUnlock() + + currentUserID := getCurrentUserID(r) + currentSession := getSession(r) + + // Build all files list + var allItems []string + var myItems []string + + for id, entry := range store.files { + displayName := entry.Name + if len(store.byName[entry.Name]) > 1 { + for i, fid := range store.byName[entry.Name] { + if fid == id { + displayName = fmt.Sprintf("%s (%d)", entry.Name, i+1) + break + } + } + } + + // Build item with edit link + item := fmt.Sprintf("
  • %s [edit]", id, displayName, id) + + // Check ownership for delete button and "my files" list + // User can delete if: logged in + owns file, OR anonymous + same session + file not claimed + isOwner := (currentUserID != "" && entry.UserID == currentUserID) || + (currentSession != nil && entry.SessionID == currentSession.ID && entry.UserID == "") + + // Add delete link if user owns the file + if isOwner { + item += fmt.Sprintf("
    ", id) + } + + // Add private indicator for non-public files + if !entry.IsPublic { + item += " 🔒" + } + + item += "
  • " + + // Add to all files list only if public + if entry.IsPublic { + allItems = append(allItems, item) + } + + // Check if this file belongs to current user for "my files" list + if isOwner { + myItems = append(myItems, item) + } + } + + var allList, myList string + + if len(allItems) > 0 { + switch ListFormat { + case "ol": + allList = "
      " + strings.Join(allItems, "") + "
    " + case "plain": + allList = strings.Join(allItems, ": ") + default: // "ul" + allList = "" + } + } + + if len(myItems) > 0 { + switch ListFormat { + case "ol": + myList = "
      " + strings.Join(myItems, "") + "
    " + case "plain": + myList = strings.Join(myItems, ": ") + default: // "ul" + myList = "" + } + myList = "

    Your Files

    " + myList + "
    " + } + + var body string + if isAuthenticated(r) { + username := "" + for _, user := range users { + if user.ID == currentUserID { + username = user.Username + break + } + } + body = "

    Welcome, " + username + "!

    " + myList + "

    All Files

    " + allList + "
    " + } else { + body = "

    Markdown Files

    " + allList + } + + renderPage(w, r, "Markdown Files - List", body) +} + +func addHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + http.Redirect(w, r, "/create", http.StatusSeeOther) + return + } + + form := `

    Add New Markdown File

    +
    + + + +
    ` + + renderPage(w, r, "Add Markdown File", form) +} + +func editHandler(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + store.mu.RLock() + defer store.mu.RUnlock() + + var links []string + for id, entry := range store.files { + links = append(links, fmt.Sprintf("
  • %s
  • ", id, entry.Name)) + } + renderPage(w, r, "Edit Markdown File", "

    Select File to Edit

    ") + return + } + + store.mu.RLock() + entry, exists := store.files[id] + store.mu.RUnlock() + + if !exists { + http.NotFound(w, r) + return + } + + path := filepath.Join(postsDir, entry.FullName) + data, err := os.ReadFile(path) + if err != nil { + http.NotFound(w, r) + return + } + + // Check if file is public for the checkbox + checked := "" + if entry.IsPublic { + checked = "checked" + } + + form := fmt.Sprintf(`

    Edit: %s

    +
    + + + + +
    `, entry.Name, id, string(data), checked) + + renderPage(w, r, "Edit "+entry.Name, form) +} + +func createHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Redirect(w, r, "/add", http.StatusSeeOther) + return + } + + title := r.FormValue("title") + content := r.FormValue("content") + + if title == "" || content == "" { + http.Error(w, "Title and content are required", http.StatusBadRequest) + return + } + + // Get or create session + sessionID := getOrCreateSession(w, r) + + safeTitle := strings.ReplaceAll(title, "/", "-") + safeTitle = strings.ReplaceAll(safeTitle, "\\", "-") + if !strings.HasSuffix(safeTitle, ".md") { + safeTitle += ".md" + } + + path := filepath.Join(postsDir, safeTitle) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed to create file: %v", err), http.StatusInternalServerError) + return + } + + // Generate ID for the new file (we need it before scanning) + info, _ := os.Stat(path) + fileID := generateID(safeTitle, info.ModTime().UnixNano()) + + // Store session ID and user ID in memory (will be preserved across scans) + store.mu.Lock() + store.files[fileID] = FileEntry{ + ID: fileID, + Name: strings.TrimSuffix(safeTitle, ".md"), + FullName: safeTitle, + SessionID: sessionID, + UserID: getCurrentUserID(r), + IsPublic: true, // New files are public by default + CreatedAt: info.ModTime().UnixNano(), + } + store.firstSeen[safeTitle] = info.ModTime().UnixNano() + store.mu.Unlock() + + // Add file to session and save + dataMu.Lock() + if session, exists := sessions[sessionID]; exists { + session.Posts = append(session.Posts, fileID) + sessions[sessionID] = session + } + _ = saveData() + dataMu.Unlock() + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func updateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Redirect(w, r, "/edit", http.StatusSeeOther) + return + } + + id := r.FormValue("id") + content := r.FormValue("content") + isPublic := r.FormValue("isPublic") == "on" + + if id == "" || content == "" { + http.Error(w, "ID and content are required", http.StatusBadRequest) + return + } + + store.mu.RLock() + entry, exists := store.files[id] + store.mu.RUnlock() + + if !exists { + http.NotFound(w, r) + return + } + + // Check if current user owns this file + currentUserID := getCurrentUserID(r) + currentSession := getSession(r) + canEdit := false + if currentUserID != "" && entry.UserID == currentUserID { + canEdit = true + } else if currentSession != nil && entry.SessionID == currentSession.ID && entry.UserID == "" { + canEdit = true + } + + if !canEdit { + http.Error(w, "You can only edit your own files", http.StatusForbidden) + return + } + + path := filepath.Join(postsDir, entry.FullName) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed to update file: %v", err), http.StatusInternalServerError) + return + } + + // Update IsPublic setting (preserve CreatedAt) + store.mu.Lock() + entry.IsPublic = isPublic + // Preserve existing CreatedAt + if existingEntry, exists := store.files[id]; exists { + entry.CreatedAt = existingEntry.CreatedAt + } + store.files[id] = entry + store.mu.Unlock() + + dataMu.Lock() + _ = saveData() + dataMu.Unlock() + + scanPosts() + http.Redirect(w, r, "/view/"+id, http.StatusSeeOther) +} + +func deleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + id := r.FormValue("id") + if id == "" { + http.Error(w, "File ID required", http.StatusBadRequest) + return + } + + store.mu.RLock() + entry, exists := store.files[id] + store.mu.RUnlock() + + if !exists { + http.NotFound(w, r) + return + } + + // Check if current user owns this file + currentUserID := getCurrentUserID(r) + currentSession := getSession(r) + + // User can delete if: + // 1. Logged in and file belongs to their user account, OR + // 2. Not logged in but file belongs to their anonymous session AND file has no claimed user + canDelete := false + if currentUserID != "" && entry.UserID == currentUserID { + canDelete = true + } else if currentSession != nil && entry.SessionID == currentSession.ID && entry.UserID == "" { + canDelete = true + } + + if !canDelete { + http.Error(w, "You can only delete your own files", http.StatusForbidden) + return + } + + // Delete the file from disk + path := filepath.Join(postsDir, entry.FullName) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + http.Error(w, fmt.Sprintf("Failed to delete file: %v", err), http.StatusInternalServerError) + return + } + + // Delete from store and session + store.mu.Lock() + delete(store.files, id) + // Remove from byName index + if idxs, exists := store.byName[entry.Name]; exists { + newIdxs := make([]string, 0, len(idxs)) + for _, idx := range idxs { + if idx != id { + newIdxs = append(newIdxs, idx) + } + } + if len(newIdxs) == 0 { + delete(store.byName, entry.Name) + } else { + store.byName[entry.Name] = newIdxs + } + } + delete(store.firstSeen, entry.FullName) + store.mu.Unlock() + + dataMu.Lock() + // Remove from session's Posts list + if session, exists := sessions[entry.SessionID]; exists { + newPosts := make([]string, 0, len(session.Posts)) + for _, pid := range session.Posts { + if pid != id { + newPosts = append(newPosts, pid) + } + } + session.Posts = newPosts + sessions[entry.SessionID] = session + } + _ = saveData() + dataMu.Unlock() + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// Auth handlers +func registerHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + // Show registration form + form := `

    Create Account

    +
    + + + +
    +

    Already have an account? Log in

    ` + renderPage(w, r, "Register", form) + return + } + + // POST: create account + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { + http.Error(w, "Username and password required", http.StatusBadRequest) + return + } + + // Check if username exists + if _, exists := users[username]; exists { + http.Error(w, "Username already taken", http.StatusBadRequest) + return + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "Failed to create account", http.StatusInternalServerError) + return + } + + // Create user + userID := generateRandomID(16) + + // Link current session to user (getOrCreateSession already locks dataMu) + sessionID := getOrCreateSession(w, r) + + dataMu.Lock() + users[username] = User{ + ID: userID, + Username: username, + Password: string(hashedPassword), + } + + if session, exists := sessions[sessionID]; exists { + session.UserID = userID + // Link all files from this session to the user + store.mu.Lock() + for _, fileID := range session.Posts { + if entry, fe := store.files[fileID]; fe { + entry.UserID = userID + store.files[fileID] = entry + } + } + store.mu.Unlock() + sessions[sessionID] = session + } + _ = saveData() // Save after modification + dataMu.Unlock() + + // Redirect to home + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func loginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + // Check if already logged in + if isAuthenticated(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + form := `

    Log In

    +
    + + + +
    +

    Create an account

    ` + renderPage(w, r, "Log In", form) + return + } + + // POST: authenticate + username := r.FormValue("username") + password := r.FormValue("password") + + user, exists := users[username] + if !exists { + http.Error(w, "Invalid username or password", http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + http.Error(w, "Invalid username or password", http.StatusUnauthorized) + return + } + + // Link session to user + sessionID := getOrCreateSession(w, r) + dataMu.Lock() + sessions[sessionID] = Session{ + ID: sessionID, + UserID: user.ID, + Expires: time.Now().Add(time.Duration(cookieMaxAge) * time.Second), + Posts: []string{}, + } + _ = saveData() + dataMu.Unlock() + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func logoutHandler(w http.ResponseWriter, r *http.Request) { + // Get current session and clear UserID + cookie, err := r.Cookie(cookieName) + if err == nil && cookie.Value != "" { + dataMu.Lock() + if session, exists := sessions[cookie.Value]; exists { + session.UserID = "" + sessions[cookie.Value] = session + _ = saveData() + } + dataMu.Unlock() + // Clear cookie + cookie.Expires = time.Unix(0, 0) + cookie.Value = "" + http.SetCookie(w, cookie) + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func claimHandler(w http.ResponseWriter, r *http.Request) { + // This is for claiming an anonymous session after creating an account + // For now, this is handled automatically in registerHandler + // Future: allow claiming via email token + http.Redirect(w, r, "/register", http.StatusSeeOther) +} + +func viewHandler(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/view/") + if id == "" { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + store.mu.RLock() + entry, exists := store.files[id] + store.mu.RUnlock() + + if !exists { + http.NotFound(w, r) + return + } + + path := filepath.Join(postsDir, entry.FullName) + data, err := os.ReadFile(path) + if err != nil { + http.NotFound(w, r) + return + } + + var buf strings.Builder + if err := markdown.Convert(data, &buf); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Use getNavBar which already includes all links + fmt.Fprintf(w, "%s%s%s%s
    %s
    ", entry.Name, css, chromaCSS, getNavBar(r), buf.String()) +}