1115 lines
34 KiB
Go
1115 lines
34 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
|
|
)
|
|
|
|
// 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>
|
|
|
|
```bash
|
|
# Bash code with syntax highlighting
|
|
echo "Hello World"
|
|
```
|
|
|
|
[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)}
|
|
</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) {
|
|
fmt.Fprintf(w, "<html><head><title>%s</title>%s%s</head><body>%s<main>%s</main></body></html>", title, chromaCSS, css, 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("<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
|
|
fmt.Fprintf(w, "<html><head><title>%s</title>%s%s</head><body>%s<main><div class='markdown-body'>%s</div></main></body></html>", entry.Name, css, chromaCSS, getNavBar(r), buf.String())
|
|
}
|