Initial commit: Phase 1+2 prototype

- Go backend with SQLite, JWT auth, file CRUD
- Vue 3 frontend with split/raw/WYSIWYG editor modes
- Markdown preview (marked, GFM)
- Formatting toolbar + keyboard shortcuts
- File tree with search, create, delete
- Light/dark theme toggle
- Admin panel (user management)
- Preferences (timezone, theme, default mode)
- Shared documents section (placeholder)
- Export: PDF, HTML, MD
- Build daemon (Python, stdlib only)
- Build job queue API
- Docker deployment
This commit is contained in:
2026-05-22 19:48:48 +02:00
commit 0c1047d390
26 changed files with 6206 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"net/http"
"github.com/google/uuid"
"markdownhub/internal/files"
)
// ─── Build Jobs ──────────────────────────────────────────────────────────────
func (s *Server) handleBuildSubmit(w http.ResponseWriter, r *http.Request) {
var req struct {
FileID string `json:"file_id"`
Path string `json:"path"`
GiteaURL string `json:"gitea_url"`
GiteaToken string `json:"gitea_token"`
GiteaOrg string `json:"gitea_org"`
RepoName string `json:"repo_name"`
Model string `json:"model"`
}
if err := decodeBody(r, &req); err != nil || req.RepoName == "" {
writeJSON(w, 400, map[string]string{"error": "repo_name required"})
return
}
userID := getUserID(r)
// Read spec content from file
var specContent string
if req.Path != "" {
content, err := files.ReadFile(s.dataDir, userID, req.Path)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "spec file not found"})
return
}
specContent = content
}
jobID := uuid.New().String()
_, err := s.db.Exec(
`INSERT INTO build_jobs (id, user_id, status, spec_content, gitea_url, gitea_token, gitea_org, repo_name, model)
VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?)`,
jobID, userID, specContent, req.GiteaURL, req.GiteaToken, req.GiteaOrg, req.RepoName, req.Model,
)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to create job"})
return
}
writeJSON(w, 201, map[string]string{"job_id": jobID, "status": "pending"})
}
func (s *Server) handleBuildJobs(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
rows, err := s.db.Query(
"SELECT id, status, repo_name, model, created_at, updated_at FROM build_jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 50",
userID,
)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "query failed"})
return
}
defer rows.Close()
var jobs []map[string]interface{}
for rows.Next() {
var id, status, repoName, model, createdAt, updatedAt string
rows.Scan(&id, &status, &repoName, &model, &createdAt, &updatedAt)
jobs = append(jobs, map[string]interface{}{
"job_id": id, "status": status, "repo_name": repoName,
"model": model, "created_at": createdAt, "updated_at": updatedAt,
})
}
if jobs == nil {
jobs = []map[string]interface{}{}
}
writeJSON(w, 200, jobs)
}
func (s *Server) handleBuildStatus(w http.ResponseWriter, r *http.Request) {
var req struct {
JobID string `json:"job_id"`
}
if err := decodeBody(r, &req); err != nil || req.JobID == "" {
writeJSON(w, 400, map[string]string{"error": "job_id required"})
return
}
var status, repoName, log, updatedAt string
err := s.db.QueryRow(
"SELECT status, repo_name, log, updated_at FROM build_jobs WHERE id = ?", req.JobID,
).Scan(&status, &repoName, &log, &updatedAt)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "job not found"})
return
}
writeJSON(w, 200, map[string]interface{}{
"job_id": req.JobID, "status": status, "repo_name": repoName,
"log": log, "updated_at": updatedAt,
})
}
func (s *Server) handleBuildCancel(w http.ResponseWriter, r *http.Request) {
var req struct {
JobID string `json:"job_id"`
}
if err := decodeBody(r, &req); err != nil || req.JobID == "" {
writeJSON(w, 400, map[string]string{"error": "job_id required"})
return
}
s.db.Exec("UPDATE build_jobs SET status = 'cancelled', updated_at = datetime('now') WHERE id = ? AND status = 'pending'", req.JobID)
writeJSON(w, 200, map[string]string{"status": "cancelled"})
}
// ─── Daemon Endpoints ────────────────────────────────────────────────────────
func (s *Server) handleDaemonPoll(w http.ResponseWriter, r *http.Request) {
var id, specContent, giteaURL, giteaToken, giteaOrg, repoName, model string
err := s.db.QueryRow(
`SELECT id, spec_content, gitea_url, gitea_token, gitea_org, repo_name, model
FROM build_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1`,
).Scan(&id, &specContent, &giteaURL, &giteaToken, &giteaOrg, &repoName, &model)
if err != nil {
// No pending jobs
writeJSON(w, 200, map[string]interface{}{})
return
}
// Mark as picked up
s.db.Exec("UPDATE build_jobs SET status = 'running', updated_at = datetime('now') WHERE id = ?", id)
writeJSON(w, 200, map[string]interface{}{
"job_id": id,
"spec_content": specContent,
"gitea_url": giteaURL,
"gitea_token": giteaToken,
"gitea_org": giteaOrg,
"repo_name": repoName,
"model": model,
})
}
func (s *Server) handleDaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
s.db.Exec(
`INSERT INTO daemon_state (id, last_heartbeat, status) VALUES ('singleton', datetime('now'), 'online')
ON CONFLICT(id) DO UPDATE SET last_heartbeat = datetime('now'), status = 'online'`,
)
writeJSON(w, 200, map[string]string{"status": "ok"})
}
func (s *Server) handleDaemonReport(w http.ResponseWriter, r *http.Request) {
var req struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Log string `json:"log"`
}
if err := decodeBody(r, &req); err != nil || req.JobID == "" {
writeJSON(w, 400, map[string]string{"error": "job_id required"})
return
}
s.db.Exec(
"UPDATE build_jobs SET status = ?, log = ?, updated_at = datetime('now') WHERE id = ?",
req.Status, req.Log, req.JobID,
)
writeJSON(w, 200, map[string]string{"status": "ok"})
}
// ─── File Search ─────────────────────────────────────────────────────────────
func (s *Server) handleSearchFiles(w http.ResponseWriter, r *http.Request) {
var req struct {
Query string `json:"query"`
}
if err := decodeBody(r, &req); err != nil || req.Query == "" {
writeJSON(w, 400, map[string]string{"error": "query required"})
return
}
userID := getUserID(r)
results, err := files.Search(s.dataDir, userID, req.Query)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "search failed"})
return
}
writeJSON(w, 200, results)
}
+248
View File
@@ -0,0 +1,248 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
"markdownhub/internal/auth"
"markdownhub/internal/files"
)
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func decodeBody(r *http.Request, v interface{}) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
// ─── Auth ────────────────────────────────────────────────────────────────────
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := decodeBody(r, &req); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request"})
return
}
var id, hash string
var isAdmin bool
err := s.db.QueryRow(
"SELECT id, password_hash, is_admin FROM users WHERE email = ?", req.Email,
).Scan(&id, &hash, &isAdmin)
if err != nil || !auth.CheckPassword(hash, req.Password) {
writeJSON(w, 401, map[string]string{"error": "invalid credentials"})
return
}
token, err := auth.CreateToken(id, isAdmin, s.secret)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "token creation failed"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "authToken",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(72 * time.Hour),
})
writeJSON(w, 200, map[string]interface{}{
"token": token,
"userId": id,
"isAdmin": isAdmin,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "authToken",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
writeJSON(w, 200, map[string]string{"status": "ok"})
}
// ─── Users ───────────────────────────────────────────────────────────────────
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
}
if err := decodeBody(r, &req); err != nil || req.Email == "" || req.Password == "" || req.Username == "" {
writeJSON(w, 400, map[string]string{"error": "username, email, and password required"})
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to hash password"})
return
}
id := uuid.New().String()
_, err = s.db.Exec(
"INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
id, req.Username, req.Email, hash, req.IsAdmin,
)
if err != nil {
writeJSON(w, 409, map[string]string{"error": "user already exists"})
return
}
// Create user's file directory
files.EnsureUserDir(s.dataDir, id)
writeJSON(w, 201, map[string]interface{}{"id": id, "username": req.Username, "email": req.Email})
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query("SELECT id, username, email, is_admin, created_at FROM users")
if err != nil {
writeJSON(w, 500, map[string]string{"error": "query failed"})
return
}
defer rows.Close()
var users []map[string]interface{}
for rows.Next() {
var id, username, email, createdAt string
var isAdmin bool
rows.Scan(&id, &username, &email, &isAdmin, &createdAt)
users = append(users, map[string]interface{}{
"id": id, "username": username, "email": email,
"isAdmin": isAdmin, "createdAt": createdAt,
})
}
if users == nil {
users = []map[string]interface{}{}
}
writeJSON(w, 200, users)
}
// ─── Files ───────────────────────────────────────────────────────────────────
func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
tree, err := files.ListTree(s.dataDir, userID)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to list files"})
return
}
if tree == nil {
tree = []files.FileInfo{}
}
writeJSON(w, 200, tree)
}
func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
content, err := files.ReadFile(s.dataDir, userID, req.Path)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "file not found"})
return
}
created := files.GetCreatedTime(s.dataDir, userID, req.Path)
writeJSON(w, 200, map[string]interface{}{"path": req.Path, "content": content, "created": created})
}
func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
if err := files.WriteFile(s.dataDir, userID, req.Path, req.Content); err != nil {
writeJSON(w, 500, map[string]string{"error": "write failed"})
return
}
writeJSON(w, 200, map[string]string{"status": "ok", "path": req.Path})
}
func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
if err := files.WriteFile(s.dataDir, userID, req.Path, req.Content); err != nil {
writeJSON(w, 500, map[string]string{"error": "create failed"})
return
}
writeJSON(w, 201, map[string]string{"status": "created", "path": req.Path})
}
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
if err := files.CreateFolder(s.dataDir, userID, req.Path); err != nil {
writeJSON(w, 500, map[string]string{"error": "create folder failed"})
return
}
writeJSON(w, 201, map[string]string{"status": "created", "path": req.Path})
}
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
if err := files.DeleteFile(s.dataDir, userID, req.Path); err != nil {
writeJSON(w, 404, map[string]string{"error": "file not found"})
return
}
writeJSON(w, 200, map[string]string{"status": "deleted"})
}
func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
// TODO: query permissions table for files shared with this user
// For now return empty list
writeJSON(w, 200, []files.FileInfo{})
}
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"context"
"net/http"
"strings"
"markdownhub/internal/auth"
)
type contextKey string
const (
ctxUserID contextKey = "userID"
ctxIsAdmin contextKey = "isAdmin"
)
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenStr := extractToken(r)
if tokenStr == "" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
userID, isAdmin, err := auth.ValidateToken(tokenStr, s.secret)
if err != nil || userID == "" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ctxUserID, userID)
ctx = context.WithValue(ctx, ctxIsAdmin, isAdmin)
next(w, r.WithContext(ctx))
}
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
isAdmin, _ := r.Context().Value(ctxIsAdmin).(bool)
if !isAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next(w, r)
})
}
func extractToken(r *http.Request) string {
// Cookie first
if c, err := r.Cookie("authToken"); err == nil && c.Value != "" {
return c.Value
}
// Bearer header
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return h[7:]
}
return ""
}
func getUserID(r *http.Request) string {
v, _ := r.Context().Value(ctxUserID).(string)
return v
}
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"database/sql"
"net/http"
"os"
"path/filepath"
)
type Server struct {
db *sql.DB
dataDir string
secret string
}
func NewRouter(db *sql.DB, dataDir, secret string) http.Handler {
s := &Server{db: db, dataDir: dataDir, secret: secret}
mux := http.NewServeMux()
// Auth
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
// Users (admin)
mux.HandleFunc("POST /api/users/create", s.requireAdmin(s.handleCreateUser))
mux.HandleFunc("POST /api/users/list", s.requireAdmin(s.handleListUsers))
// Files
mux.HandleFunc("POST /api/files/list", s.requireAuth(s.handleListFiles))
mux.HandleFunc("POST /api/files/read", s.requireAuth(s.handleReadFile))
mux.HandleFunc("POST /api/files/write", s.requireAuth(s.handleWriteFile))
mux.HandleFunc("POST /api/files/create", s.requireAuth(s.handleCreateFile))
mux.HandleFunc("POST /api/files/create-folder", s.requireAuth(s.handleCreateFolder))
mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile))
mux.HandleFunc("POST /api/files/search", s.requireAuth(s.handleSearchFiles))
mux.HandleFunc("POST /api/files/shared", s.requireAuth(s.handleSharedFiles))
// Build jobs
mux.HandleFunc("POST /api/build/submit", s.requireAuth(s.handleBuildSubmit))
mux.HandleFunc("POST /api/build/jobs", s.requireAuth(s.handleBuildJobs))
mux.HandleFunc("POST /api/build/status", s.requireAuth(s.handleBuildStatus))
mux.HandleFunc("POST /api/build/cancel", s.requireAuth(s.handleBuildCancel))
// Daemon endpoints
mux.HandleFunc("POST /api/daemon/poll", s.requireAuth(s.handleDaemonPoll))
mux.HandleFunc("POST /api/daemon/heartbeat", s.requireAuth(s.handleDaemonHeartbeat))
mux.HandleFunc("POST /api/daemon/report", s.requireAuth(s.handleDaemonReport))
// Static frontend
frontendDir := filepath.Join(dataDir, "..", "frontend", "dist")
if _, err := os.Stat(frontendDir); err != nil {
frontendDir = "./frontend/dist"
}
fs := http.FileServer(http.Dir(frontendDir))
mux.Handle("/", fs)
return mux
}