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. Add a file: Click "add" to create a new markdown file
  3. Edit a file: Click "edit" to modify existing files, or use the [edit] link next to each file in the list
  4. View a file: Click any file name in the list to view its rendered content

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()) }