Files
mdhost/main.go
T
Florian.Walther e215b1d3b6
Docker Release Build / push_to_registry (push) Successful in 54s
added footer and app_version
2026-05-02 16:04:54 +02:00

1121 lines
35 KiB
Go

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 := `<button id="theme-toggle" aria-label="Toggle dark mode">🌓</button>`
if isAuthenticated(r) {
username := ""
userID := getCurrentUserID(r)
for _, user := range users {
if user.ID == userID {
username = user.Username
break
}
}
return fmt.Sprintf(`<nav><a href="/home">home</a> | <a href="/">list</a> | <a href="/add">add</a> | <a href="/edit">edit</a> | <span class="logged-in">Logged in as %s</span> | <a href="/logout">logout</a> | %s</nav>`, username, themeToggle)
}
return `<nav><a href="/home">home</a> | <a href="/">list</a> | <a href="/add">add</a> | <a href="/edit">edit</a> | <a href="/login">login</a> | <a href="/register">register</a> | ` + themeToggle + `</nav>`
}
// 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 = `<h1>Welcome to mdhost</h1>
<p>mdhost is a simple Go web application for hosting and sharing markdown files.</p>
<h2>Features</h2>
<ul>
<li>Create, edit, and view markdown files</li>
<li>Unique identifiers for each file (stable even after edits)</li>
<li>Syntax highlighting for code blocks in <code>bash</code>, <code>go</code>, <code>python</code>, and more</li>
<li>Responsive design - works on mobile and desktop</li>
<li>Nav bar with: home, list, add, edit</li>
</ul>
<h2>Quick Start</h2>
<ol>
<li><strong>List files:</strong> Click "list" to see all markdown files</li>
<li><strong>Add a file:</strong> Click "add" to create a new markdown file</li>
<li><strong>Edit a file:</strong> Click "edit" to modify existing files, or use the [edit] link next to each file in the list</li>
<li><strong>View a file:</strong> Click any file name in the list to view its rendered content</li>
</ol>
<h2>Markdown Tips</h2>
<pre><code># Heading 1
## Heading 2
- List item
- Another item
<code>code block</code>
&#96;&#96;&#96;bash
# Bash code with syntax highlighting
echo "Hello World"
&#96;&#96;&#96;
[Link](https://example.com)</code></pre>
<p>Enjoy using mdhost!</p>`
// 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 = `<style>
:root{
--bg-color:#fafafa;
--text-color:#333;
--heading-color:#2c3e50;
--nav-bg:#2c3e50;
--nav-text:#ecf0f1;
--link-color:#3498db;
--code-bg:#f6f8fa;
--code-text:#333;
--input-border:#ddd;
--button-bg:#3498db;
--delete-bg:#e74c3c;
--blockquote-border:#3498db;
--blockquote-text:#555;
--private-color:#95a5a6;
--table-border:#ddd;
--table-bg:#fafafa;
--table-header-bg:#f0f0f0;
--table-row-alt:#f5f5f5;
}
.dark{
--bg-color:#1e1e1e;
--table-border:#444;
--table-bg:#2a2a2a;
--table-header-bg:#333;
--table-row-alt:#252525;
--text-color:#e0e0e0;
--heading-color:#e0e0e0;
--nav-bg:#2c3e50;
--nav-text:#ffffff;
--link-color:#58a6ff;
--code-bg:#1a1a1a;
--code-text:#ffffff;
--input-border:#444;
--button-bg:#0d6efd;
--delete-bg:#da3633;
--blockquote-border:#58a6ff;
--blockquote-text:#aaa;
--private-color:#8892b0;
}
*:before,*:after{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.6;color:var(--text-color);background:var(--bg-color);transition:background .3s,color .3s}
nav{background:var(--nav-bg);padding:.5rem;position:sticky;top:0;z-index:100}
nav a{color:var(--nav-text);text-decoration:none;margin:0 .5rem;padding:.25rem .5rem;border-radius:4px;transition:background .2s}
nav a:hover{background:rgba(255,255,255,.1)}
nav .logged-in{color:var(--nav-text);font-weight:600;margin:0 .5rem;padding:.25rem .5rem}
main{padding:1rem;max-width:800px;margin:0 auto}
h1,h2{color:var(--heading-color);margin-top:0}
ul,ol{padding-left:1.5rem}
li{margin:.5rem 0}
form{margin:1rem 0}
label{display:block;margin:.5rem 0 .25rem;font-weight:600;color:var(--text-color)}
input[type="text"],input[type="password"]{width:100%;padding:.5rem;font-size:1rem;border:1px solid var(--input-border);border-radius:4px;font-family:inherit;background:var(--bg-color);color:var(--text-color)}
textarea{width:100%;padding:.75rem;font-size:1rem;border:1px solid var(--input-border);border-radius:4px;font-family:inherit;min-height:400px;background:var(--bg-color);color:var(--text-color)}
@media(min-height:700px){textarea{min-height:500px}}
button{background:var(--button-bg);color:#fff;border:none;padding:.5rem 1rem;border-radius:4px;cursor:pointer;font-size:1rem;transition:background .2s}
button:hover{background:var(--link-color)}
.delete-btn{background:var(--delete-bg);color:#fff;padding:.1rem .3rem;font-size:.7rem;border-radius:3px;cursor:pointer;transition:background .2s}
.delete-btn:hover{background:var(--delete-bg)}
.private-indicator{color:var(--private-color);font-size:.8rem;vertical-align:middle}
.markdown-body{line-height:1.7;color:var(--text-color)}
.markdown-body h1,.markdown-body h2,.markdown-body h3{margin-top:1.5rem;color:var(--heading-color)}
.markdown-body p{margin:.75rem 0}
.markdown-body pre{background:var(--code-bg);padding:1rem;border-radius:4px;overflow-x:auto;color:var(--code-text)}
.markdown-body code{background:var(--code-bg);padding:.1rem .3rem;border-radius:3px;font-family:monospace;font-size:.9rem;color:var(--code-text)}
.markdown-body blockquote{border-left:3px solid var(--blockquote-border);padding-left:1rem;margin-left:0;color:var(--blockquote-text)}
.markdown-body table{border-collapse:collapse;width:100%;margin:1rem 0;background:var(--table-bg);border:1px solid var(--table-border)}
.markdown-body th{padding:.75rem;text-align:left;border:1px solid var(--table-border);background:var(--table-header-bg);color:var(--heading-color);font-weight:600}
.markdown-body td{padding:.75rem;text-align:left;border:1px solid var(--table-border);color:var(--text-color)}
.markdown-body tr:nth-child(even) td{background:var(--table-row-alt)}
.markdown-body tr:nth-child(odd) td{background:var(--table-bg)}
.markdown-body pre code{background:transparent;padding:0;font-size:inherit}
.dark .chroma{color:var(--code-text)!important;background-color:var(--code-bg)!important}
.dark .chroma .lnt{color:var(--private-color)!important}
.dark .chroma .ln{color:var(--private-color)!important}
.dark pre{background-color:var(--code-bg)!important}
@media(max-width:600px){nav{padding:.5rem;flex-wrap:wrap;display:flex;gap:.5rem}nav a{padding:.25rem .5rem;font-size:.9rem}main{padding:.75rem}}
.edit-link{color:var(--private-color);font-size:.8rem;text-decoration:none}
.edit-link:hover{color:var(--link-color)}
#theme-toggle{background:none;border:none;cursor:pointer;padding:.25rem .4rem;color:var(--nav-text);font-size:1.1rem;border-radius:4px}
#theme-toggle:hover{background:rgba(255,255,255,.1)}
footer{position:fixed;bottom:0;left:0;width:100%;background:var(--nav-bg);color:var(--nav-text);padding:.5rem 1rem;display:flex;justify-content:space-between;align-items:center;font-size:.85rem;z-index:100}
footer .left{font-weight:600}
footer .right{font-family:monospace}
</style>
<script>
document.addEventListener('DOMContentLoaded',function(){
const html = document.documentElement;
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme:dark)').matches;
if(saved === 'dark' || (!saved && prefersDark)){
html.classList.add('dark');
}
const toggle = document.getElementById('theme-toggle');
if(toggle){
toggle.addEventListener('click',function(){
html.classList.toggle('dark');
localStorage.setItem('theme', html.classList.contains('dark') ? 'dark' : 'light');
});
}
});
</script>
<meta name="viewport" content="width=device-width,initial-scale=1">`
const chromaCSS = `<style>
.lntd{vertical-align:top;padding:0;margin:0;border:0}.lntable-busy{opacity:.5}.chroma{color:#383a42;background-color:#f6f8fa}.chroma .x{}.chroma .err{color:#a61717;background-color:#e3d2d2}.chroma .lnt{color:#383a42}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable-busy{opacity:.5}.chroma .hl{display:block;width:100%;background-color:#ffdd40}.chroma .lnt{color:#383a42}.chroma .ln{color:#383a42}.chroma .k{color:#000;font-weight:bold}.chroma .kc{color:#000;font-weight:bold}.chroma .kd{color:#000;font-weight:bold}.chroma .kn{color:#000;font-weight:bold}.chroma .kp{color:#000;font-weight:bold}.chroma .kr{color:#000;font-weight:bold}.chroma .kt{color:#445588;font-weight:bold}.chroma .na{color:#008080}.chroma .nb{color:#008080}.chroma .nc{color:#445588;font-weight:bold}.chroma .no{color:#008080}.chroma .nd{color:#3c5d5b;font-weight:bold}.chroma .ni{color:#800080;font-weight:bold}.chroma .ne{color:#800080;font-weight:bold}.chroma .nf{color:#008080}.chroma .nl{color:#008080}.chroma .nn{color:#005580}.chroma .nx{color:#008080}.chroma .nt{color:#000;font-weight:bold}.chroma .vc{color:#008080}.chroma .vg{color:#008080}.chroma .vi{color:#008080}.chroma .nv{color:#008080}.chroma .va{color:#008080}.chroma .vd{color:#008080}.chroma .ve{color:#008080}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .s{color:#d14}.chroma .sa{color:#d14}.chroma .sb{color:#d14}.chroma .sc{color:#d14}.chroma .dl{color:#d14}.chroma .sd{color:#d14}.chroma .s2{color:#d14}.chroma .se{color:#d14}.chroma .sh{color:#d14}.chroma .si{color:#d14}.chroma .sx{color:#d14}.chroma .sr{color:#d14}.chroma .s1{color:#d14}.chroma .ss{color:#d14}.chroma .m{color:#009999}.chroma .mb{color:#009999}.chroma .mf{color:#009999}.chroma .mh{color:#009999}.chroma .mi{color:#009999}.chroma .mo{color:#009999}.chroma .il{color:#009999}.chroma .c{color:#999988;font-style:italic}.chroma .ch{color:#999988;font-style:italic}.chroma .cm{color:#999988;font-style:italic}.chroma .c1{color:#999988;font-style:italic}.chroma .cs{color:#999988;font-style:italic}.chroma .cp{color:#999988;font-style:italic}.chroma .cpf{color:#999988;font-style:italic}.chroma .gd{color:#000;font-style:italic}.chroma .ge{font-style:italic}.chroma .gr{color:#ff0000}.chroma .gh{color:#999999}.chroma .gi{color:#000;font-style:italic}.chroma .gs{font-weight:bold}.chroma .gu{color:#000;font-style:italic}.chroma .gp{color:#000;font-style:italic}.chroma .go{color:#888888}.chroma .gt{color:#0044dd}.chroma .gd{color:#000;font-style:italic}.chroma .ge{font-style:italic}.chroma .gc{color:#999988;font-style:italic}.chroma .gb{color:#000;font-weight:bold}.chroma .ga{color:#000;font-style:italic}.chroma .gn{color:#000;font-weight:bold}.chroma .gm{color:#000;font-style:italic}.chroma .gp{color:#000;font-style:italic}.chroma .gs{font-weight:bold}
</style>`
func renderPage(w http.ResponseWriter, r *http.Request, title, body string) {
footer := fmt.Sprintf("<footer><span class='left'>mdhost</span><span class='center'></span><span class='right'>%s</span></footer>", version)
fmt.Fprintf(w, "<html><head><title>%s</title>%s%s</head><body>%s<main>%s</main>%s</body></html>", title, chromaCSS, css, getNavBar(r), body, footer)
}
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("<li><a href='/view/%s'>%s</a> <a href='/edit?id=%s' class='edit-link'>[edit]</a>", 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(" <form action='/delete' method='POST' style='display:inline'> <input type='hidden' name='id' value='%s'> <button type='submit' class='delete-btn' onclick='return confirm(\"Are you sure?\")'>[delete]</button> </form>", id)
}
// Add private indicator for non-public files
if !entry.IsPublic {
item += " <span class='private-indicator' title='Not listed'>🔒</span>"
}
item += "</li>"
// 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 = "<ol>" + strings.Join(allItems, "") + "</ol>"
case "plain":
allList = strings.Join(allItems, ": ")
default: // "ul"
allList = "<ul>" + strings.Join(allItems, "") + "</ul>"
}
}
if len(myItems) > 0 {
switch ListFormat {
case "ol":
myList = "<ol>" + strings.Join(myItems, "") + "</ol>"
case "plain":
myList = strings.Join(myItems, ": ")
default: // "ul"
myList = "<ul>" + strings.Join(myItems, "") + "</ul>"
}
myList = "<h2>Your Files</h2><div class='markdown-body'>" + myList + "</div>"
}
var body string
if isAuthenticated(r) {
username := ""
for _, user := range users {
if user.ID == currentUserID {
username = user.Username
break
}
}
body = "<h1>Welcome, " + username + "!</h1>" + myList + "<h2>All Files</h2><div class='markdown-body'>" + allList + "</div>"
} else {
body = "<h1>Markdown Files</h1>" + 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 := `<h1>Add New Markdown File</h1>
<form action="/create" method="POST">
<label>Title: <input type="text" name="title" required placeholder="My new post"></label>
<label>Content:
<textarea name="content" rows="10" placeholder="# Hello\n\nThis is my markdown content..." required></textarea></label>
<button type="submit">Create</button>
</form>`
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("<li><a href='/edit?id=%s'>%s</a></li>", id, entry.Name))
}
renderPage(w, r, "Edit Markdown File", "<h1>Select File to Edit</h1><div class='markdown-body'><ul>"+strings.Join(links, "")+"</ul></div>")
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(`<h1>Edit: %s</h1>
<form action="/update" method="POST">
<input type="hidden" name="id" value="%s">
<label>Content:
<textarea name="content" rows="10">%s</textarea></label>
<label><input type="checkbox" name="isPublic" %s> Listed in public list</label>
<button type="submit">Save</button>
</form>`, 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 := `<h1>Create Account</h1>
<form action="/register" method="POST">
<label>Username: <input type="text" name="username" required></label>
<label>Password: <input type="password" name="password" required minlength="8"></label>
<button type="submit">Register</button>
</form>
<p><a href="/login">Already have an account? Log in</a></p>`
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 := `<h1>Log In</h1>
<form action="/login" method="POST">
<label>Username: <input type="text" name="username" required></label>
<label>Password: <input type="password" name="password" required></label>
<button type="submit">Log In</button>
</form>
<p><a href="/register">Create an account</a></p>`
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
footer := fmt.Sprintf("<footer><span class='left'>mdhost</span><span class='center'></span><span class='right'>%s</span></footer>", version)
fmt.Fprintf(w, "<html><head><title>%s</title>%s%s</head><body>%s<main><div class='markdown-body'>%s</div></main>%s</body></html>", entry.Name, css, chromaCSS, getNavBar(r), buf.String(), footer)
}