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:
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func CreateToken(userID string, isAdmin bool, secret string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"admin": isAdmin,
|
||||
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ValidateToken(tokenStr, secret string) (userID string, isAdmin bool, err error) {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return "", false, err
|
||||
}
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
userID, _ = claims["sub"].(string)
|
||||
isAdmin, _ = claims["admin"].(bool)
|
||||
return userID, isAdmin, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// DeriveKey derives a 256-bit key from password and salt using Argon2id.
|
||||
func DeriveKey(password []byte, salt []byte) []byte {
|
||||
return argon2.IDKey(password, salt, 3, 64*1024, 4, 32)
|
||||
}
|
||||
|
||||
// GenerateSalt returns a random 16-byte salt.
|
||||
func GenerateSalt() ([]byte, error) {
|
||||
salt := make([]byte, 16)
|
||||
_, err := io.ReadFull(rand.Reader, salt)
|
||||
return salt, err
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext with AES-256-GCM using the given key.
|
||||
func Encrypt(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts AES-256-GCM ciphertext using the given key.
|
||||
func Decrypt(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB = sql.DB
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
database, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := database.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func Migrate(database *DB) error {
|
||||
_, err := database.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
var schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
totp_secret TEXT,
|
||||
encryption_enabled INTEGER DEFAULT 0,
|
||||
encryption_salt BLOB,
|
||||
recovery_key_hash TEXT,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
path TEXT NOT NULL,
|
||||
title TEXT,
|
||||
encrypted INTEGER DEFAULT 0,
|
||||
encrypted_content BLOB,
|
||||
encrypted_file_key BLOB,
|
||||
sync_flagged INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(owner_id, path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS virtual_tree (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
parent_id TEXT REFERENCES virtual_tree(id),
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target_file_id TEXT REFERENCES files(id),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
file_id TEXT NOT NULL REFERENCES files(id),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
level TEXT NOT NULL,
|
||||
granted_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(file_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS git_remotes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
auth_token TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_state (
|
||||
file_id TEXT PRIMARY KEY REFERENCES files(id),
|
||||
yjs_state BLOB,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS build_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
spec_content TEXT NOT NULL,
|
||||
gitea_url TEXT,
|
||||
gitea_token TEXT,
|
||||
gitea_org TEXT,
|
||||
repo_name TEXT NOT NULL,
|
||||
model TEXT,
|
||||
log TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daemon_state (
|
||||
id TEXT PRIMARY KEY DEFAULT 'singleton',
|
||||
last_heartbeat TEXT,
|
||||
status TEXT DEFAULT 'offline'
|
||||
);
|
||||
`
|
||||
@@ -0,0 +1,124 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Children []FileInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// UserDir returns the base directory for a user's files.
|
||||
func UserDir(dataDir, userID string) string {
|
||||
return filepath.Join(dataDir, "files", userID)
|
||||
}
|
||||
|
||||
// EnsureUserDir creates the user's file directory if it doesn't exist.
|
||||
func EnsureUserDir(dataDir, userID string) error {
|
||||
return os.MkdirAll(UserDir(dataDir, userID), 0755)
|
||||
}
|
||||
|
||||
// ReadFile reads a markdown file for a user.
|
||||
func ReadFile(dataDir, userID, relPath string) (string, error) {
|
||||
p := safePath(dataDir, userID, relPath)
|
||||
if p == "" {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// WriteFile writes content to a markdown file for a user.
|
||||
func WriteFile(dataDir, userID, relPath, content string) error {
|
||||
p := safePath(dataDir, userID, relPath)
|
||||
if p == "" {
|
||||
return os.ErrPermission
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, []byte(content), 0644)
|
||||
}
|
||||
|
||||
// CreateFolder creates a directory for a user.
|
||||
func CreateFolder(dataDir, userID, relPath string) error {
|
||||
p := safePath(dataDir, userID, relPath)
|
||||
if p == "" {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return os.MkdirAll(p, 0755)
|
||||
}
|
||||
|
||||
// DeleteFile removes a file or folder for a user.
|
||||
func DeleteFile(dataDir, userID, relPath string) error {
|
||||
p := safePath(dataDir, userID, relPath)
|
||||
if p == "" {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return os.RemoveAll(p)
|
||||
}
|
||||
|
||||
// ListTree returns the file tree for a user.
|
||||
func ListTree(dataDir, userID string) ([]FileInfo, error) {
|
||||
root := UserDir(dataDir, userID)
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listDir(root, "")
|
||||
}
|
||||
|
||||
func listDir(base, rel string) ([]FileInfo, error) {
|
||||
dir := filepath.Join(base, rel)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []FileInfo
|
||||
for _, e := range entries {
|
||||
entryRel := filepath.Join(rel, e.Name())
|
||||
info := FileInfo{
|
||||
Name: e.Name(),
|
||||
Path: entryRel,
|
||||
IsDir: e.IsDir(),
|
||||
}
|
||||
if e.IsDir() {
|
||||
children, err := listDir(base, entryRel)
|
||||
if err == nil {
|
||||
info.Children = children
|
||||
}
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// safePath validates and returns the absolute path, preventing traversal.
|
||||
func safePath(dataDir, userID, relPath string) string {
|
||||
if strings.Contains(relPath, "..") {
|
||||
return ""
|
||||
}
|
||||
root := UserDir(dataDir, userID)
|
||||
p := filepath.Join(root, filepath.Clean(relPath))
|
||||
if !strings.HasPrefix(p, root) {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// GetCreatedTime returns the file's modification time as ISO string.
|
||||
func GetCreatedTime(dataDir, userID, relPath string) string {
|
||||
p := safePath(dataDir, userID, relPath)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return info.ModTime().UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
Path string `json:"path"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
// Search finds files containing the query string (case-insensitive).
|
||||
func Search(dataDir, userID, query string) ([]SearchResult, error) {
|
||||
root := UserDir(dataDir, userID)
|
||||
query = strings.ToLower(query)
|
||||
var results []SearchResult
|
||||
|
||||
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
|
||||
// Match filename
|
||||
if strings.Contains(strings.ToLower(info.Name()), query) {
|
||||
results = append(results, SearchResult{Path: rel, Snippet: "(filename match)"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match content (only for .md and .txt)
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == ".md" || ext == ".txt" {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(string(content))
|
||||
idx := strings.Index(lower, query)
|
||||
if idx >= 0 {
|
||||
// Extract snippet
|
||||
start := idx - 40
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + len(query) + 40
|
||||
if end > len(content) {
|
||||
end = len(content)
|
||||
}
|
||||
snippet := string(content[start:end])
|
||||
results = append(results, SearchResult{Path: rel, Snippet: snippet})
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) >= 50 {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if results == nil {
|
||||
results = []SearchResult{}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
Reference in New Issue
Block a user