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/extension" "github.com/yuin/goldmark-highlighting" "golang.org/x/crypto/bcrypt" ) var ( postsDir = "./posts" dataDir = "./data" port = ":8080" markdown = newMarkdownRenderer() dataMu sync.RWMutex version = "dev" ) // 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( extension.Table, 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 { themeToggle := `` if isAuthenticated(r) { username := "" userID := getCurrentUserID(r) for _, user := range users { if user.ID == userID { username = user.Username break } } return fmt.Sprintf(``, username, themeToggle) } 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 = `
mdhost is a simple Go web application for hosting and sharing markdown files.
bash, go, python, and more# 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) { footer := fmt.Sprintf("", version) fmt.Fprintf(w, "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 := `