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,5 @@
|
|||||||
|
data/
|
||||||
|
frontend/node_modules/
|
||||||
|
server
|
||||||
|
mdsync
|
||||||
|
*.exe
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.23-alpine AS backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go build -o /markdownhub ./cmd/server
|
||||||
|
RUN go build -o /mdsync ./cmd/mdsync
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /markdownhub /app/markdownhub
|
||||||
|
COPY --from=backend /mdsync /app/mdsync
|
||||||
|
COPY --from=frontend /app/frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
|
ENV MH_DATA_DIR=/app/data
|
||||||
|
ENV MH_PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/markdownhub"]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# MarkdownHub
|
||||||
|
|
||||||
|
Self-hosted collaborative markdown workspace.
|
||||||
|
|
||||||
|
See [SPEC.md](../SPEC.md) for full specification.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Backend (Go)
|
||||||
|
```bash
|
||||||
|
cd project
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (Vue)
|
||||||
|
```bash
|
||||||
|
cd project/frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
cd project
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
cmd/server/ — Web server entry point
|
||||||
|
cmd/mdsync/ — CLI sync tool
|
||||||
|
internal/api/ — HTTP handlers & routing
|
||||||
|
internal/db/ — SQLite connection & schema
|
||||||
|
internal/auth/ — Authentication & sessions
|
||||||
|
internal/files/ — File CRUD operations
|
||||||
|
internal/git/ — Git operations (go-git)
|
||||||
|
internal/collab/ — Yjs WebSocket relay
|
||||||
|
internal/crypto/ — AES-256-GCM encryption
|
||||||
|
internal/ai/ — LiteLLM integration
|
||||||
|
frontend/ — Vue 3 SPA
|
||||||
|
```
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MarkdownHub Build Daemon.
|
||||||
|
|
||||||
|
Polls MarkdownHub for pending build jobs, runs Pi coding agent
|
||||||
|
in a sandboxed workspace, and pushes results to Gitea.
|
||||||
|
|
||||||
|
No external dependencies — stdlib only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ─── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
MARKDOWNHUB_URL = os.environ.get("MH_URL", "http://localhost:8090")
|
||||||
|
DAEMON_TOKEN = os.environ.get("MH_DAEMON_TOKEN", "")
|
||||||
|
WORKSPACE_ROOT = Path(os.environ.get("MH_WORKSPACE", os.path.expanduser("~/builds")))
|
||||||
|
POLL_INTERVAL = int(os.environ.get("MH_POLL_INTERVAL", "10")) # seconds
|
||||||
|
MAX_CONCURRENT = int(os.environ.get("MH_MAX_CONCURRENT", "1"))
|
||||||
|
DEFAULT_MODEL = os.environ.get("MH_DEFAULT_MODEL", "")
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def api_post(path, data=None):
|
||||||
|
"""POST to MarkdownHub API."""
|
||||||
|
url = f"{MARKDOWNHUB_URL}/api/{path}"
|
||||||
|
body = json.dumps(data or {}).encode()
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {DAEMON_TOKEN}",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
log(f" API error {e.code}: {e.read().decode()}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Connection error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat():
|
||||||
|
"""Send heartbeat to MarkdownHub."""
|
||||||
|
api_post("daemon/heartbeat", {"status": "online"})
|
||||||
|
|
||||||
|
|
||||||
|
def poll_job():
|
||||||
|
"""Poll for a pending build job."""
|
||||||
|
return api_post("daemon/poll")
|
||||||
|
|
||||||
|
|
||||||
|
def report_status(job_id, status, log_text=""):
|
||||||
|
"""Report job status back to MarkdownHub."""
|
||||||
|
api_post("daemon/report", {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": status,
|
||||||
|
"log": log_text,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def setup_workspace(project_name, gitea_url, gitea_token, gitea_org, repo_name):
|
||||||
|
"""Create project folder and initialize git repo."""
|
||||||
|
project_dir = WORKSPACE_ROOT / project_name
|
||||||
|
if project_dir.exists():
|
||||||
|
# Clean existing
|
||||||
|
subprocess.run(["rm", "-rf", str(project_dir)], check=True)
|
||||||
|
|
||||||
|
project_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Init git and set remote
|
||||||
|
remote_url = f"{gitea_url}/{gitea_org}/{repo_name}.git"
|
||||||
|
# Embed token in URL for push
|
||||||
|
if gitea_token and "://" in remote_url:
|
||||||
|
scheme, rest = remote_url.split("://", 1)
|
||||||
|
remote_url = f"{scheme}://oauth2:{gitea_token}@{rest}"
|
||||||
|
|
||||||
|
subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "origin", remote_url],
|
||||||
|
cwd=project_dir, check=True, capture_output=True)
|
||||||
|
|
||||||
|
return project_dir
|
||||||
|
|
||||||
|
|
||||||
|
def create_gitea_repo(gitea_url, gitea_token, org, repo_name, private=True):
|
||||||
|
"""Create a repo on Gitea via API."""
|
||||||
|
url = f"{gitea_url}/api/v1/orgs/{org}/repos"
|
||||||
|
data = json.dumps({"name": repo_name, "private": private}).encode()
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"token {gitea_token}",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 409:
|
||||||
|
log(f" Repo {org}/{repo_name} already exists")
|
||||||
|
return {"exists": True}
|
||||||
|
log(f" Gitea error {e.code}: {e.read().decode()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_pi(project_dir, spec_content, model):
|
||||||
|
"""Run Pi coding agent in the project directory."""
|
||||||
|
# Write spec to project dir
|
||||||
|
spec_file = project_dir / "spec.md"
|
||||||
|
spec_file.write_text(spec_content, encoding="utf-8")
|
||||||
|
|
||||||
|
# Build Pi command
|
||||||
|
cmd = ["pi", "-p"]
|
||||||
|
prompt = (
|
||||||
|
"Read spec.md in the current directory. "
|
||||||
|
"Implement the complete project as described. "
|
||||||
|
"Create all necessary files, folders, configs, and documentation. "
|
||||||
|
"When done, do NOT commit or push — just write the files."
|
||||||
|
)
|
||||||
|
cmd.append(prompt)
|
||||||
|
|
||||||
|
if model:
|
||||||
|
cmd.extend(["--model", model])
|
||||||
|
|
||||||
|
log(f" Running: {' '.join(cmd[:4])}...")
|
||||||
|
|
||||||
|
# Run Pi
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=project_dir,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_lines = []
|
||||||
|
for line in proc.stdout:
|
||||||
|
output_lines.append(line)
|
||||||
|
if len(output_lines) % 20 == 0:
|
||||||
|
log(f" ... {len(output_lines)} lines of output")
|
||||||
|
|
||||||
|
proc.wait()
|
||||||
|
output = "".join(output_lines)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
log(f" Pi exited with code {proc.returncode}")
|
||||||
|
return False, output
|
||||||
|
|
||||||
|
return True, output
|
||||||
|
|
||||||
|
|
||||||
|
def git_commit_and_push(project_dir, message="AI-generated project"):
|
||||||
|
"""Commit all files and push to remote."""
|
||||||
|
try:
|
||||||
|
subprocess.run(["git", "add", "."], cwd=project_dir, check=True, capture_output=True)
|
||||||
|
|
||||||
|
# Check if there's anything to commit
|
||||||
|
result = subprocess.run(["git", "status", "--porcelain"],
|
||||||
|
cwd=project_dir, capture_output=True, text=True)
|
||||||
|
if not result.stdout.strip():
|
||||||
|
log(" Nothing to commit")
|
||||||
|
return True
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "-m", message],
|
||||||
|
cwd=project_dir, check=True, capture_output=True,
|
||||||
|
env={**os.environ, "GIT_AUTHOR_NAME": "MarkdownHub", "GIT_AUTHOR_EMAIL": "build@markdownhub",
|
||||||
|
"GIT_COMMITTER_NAME": "MarkdownHub", "GIT_COMMITTER_EMAIL": "build@markdownhub"},
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "push", "-u", "origin", "main"],
|
||||||
|
cwd=project_dir, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
log(" Pushed to remote")
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
log(f" Git error: {e.stderr.decode() if e.stderr else e}")
|
||||||
|
# Try with master branch
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "push", "-u", "origin", "master"],
|
||||||
|
cwd=project_dir, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def process_job(job):
|
||||||
|
"""Process a single build job."""
|
||||||
|
job_id = job["job_id"]
|
||||||
|
project_name = job.get("repo_name", f"project-{job_id[:8]}")
|
||||||
|
spec_content = job.get("spec_content", "")
|
||||||
|
gitea_url = job.get("gitea_url", "")
|
||||||
|
gitea_token = job.get("gitea_token", "")
|
||||||
|
gitea_org = job.get("gitea_org", "")
|
||||||
|
model = job.get("model", DEFAULT_MODEL)
|
||||||
|
|
||||||
|
log(f"Processing job {job_id}: {gitea_org}/{project_name}")
|
||||||
|
report_status(job_id, "running", "Setting up workspace...")
|
||||||
|
|
||||||
|
# Create Gitea repo
|
||||||
|
if gitea_url and gitea_token and gitea_org:
|
||||||
|
result = create_gitea_repo(gitea_url, gitea_token, gitea_org, project_name)
|
||||||
|
if result is None:
|
||||||
|
report_status(job_id, "failed", "Failed to create Gitea repo")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Setup workspace
|
||||||
|
try:
|
||||||
|
project_dir = setup_workspace(project_name, gitea_url, gitea_token, gitea_org, project_name)
|
||||||
|
except Exception as e:
|
||||||
|
report_status(job_id, "failed", f"Workspace setup failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
report_status(job_id, "running", "Running Pi agent...")
|
||||||
|
|
||||||
|
# Run Pi
|
||||||
|
success, output = run_pi(project_dir, spec_content, model)
|
||||||
|
if not success:
|
||||||
|
report_status(job_id, "failed", f"Pi failed:\n{output[-2000:]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
report_status(job_id, "running", "Committing and pushing...")
|
||||||
|
|
||||||
|
# Commit and push
|
||||||
|
if git_commit_and_push(project_dir, f"AI-generated: {project_name}"):
|
||||||
|
repo_url = f"{gitea_url}/{gitea_org}/{project_name}"
|
||||||
|
report_status(job_id, "done", f"Success! Repo: {repo_url}")
|
||||||
|
log(f" Done: {repo_url}")
|
||||||
|
else:
|
||||||
|
report_status(job_id, "failed", f"Git push failed\n{output[-1000:]}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config():
|
||||||
|
"""Check required configuration."""
|
||||||
|
if not DAEMON_TOKEN:
|
||||||
|
print("Error: MH_DAEMON_TOKEN environment variable required")
|
||||||
|
print("\nConfiguration (environment variables):")
|
||||||
|
print(" MH_URL MarkdownHub URL (default: http://localhost:8090)")
|
||||||
|
print(" MH_DAEMON_TOKEN API token for daemon auth (required)")
|
||||||
|
print(" MH_WORKSPACE Build workspace root (default: ~/builds)")
|
||||||
|
print(" MH_POLL_INTERVAL Poll interval in seconds (default: 10)")
|
||||||
|
print(" MH_MAX_CONCURRENT Max concurrent jobs (default: 1)")
|
||||||
|
print(" MH_DEFAULT_MODEL Default LLM model for Pi")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
validate_config()
|
||||||
|
|
||||||
|
log(f"MarkdownHub Build Daemon starting")
|
||||||
|
log(f" Server: {MARKDOWNHUB_URL}")
|
||||||
|
log(f" Workspace: {WORKSPACE_ROOT}")
|
||||||
|
log(f" Poll interval: {POLL_INTERVAL}s")
|
||||||
|
log(f" Max concurrent: {MAX_CONCURRENT}")
|
||||||
|
|
||||||
|
last_heartbeat = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Heartbeat every 30s
|
||||||
|
now = time.time()
|
||||||
|
if now - last_heartbeat > 30:
|
||||||
|
heartbeat()
|
||||||
|
last_heartbeat = now
|
||||||
|
|
||||||
|
# Poll for job
|
||||||
|
job = poll_job()
|
||||||
|
if job and job.get("job_id"):
|
||||||
|
process_job(job)
|
||||||
|
else:
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("Shutting down...")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Error: {e}")
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
markdownhub:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8090:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- MH_SECRET=change-me-to-random-secret
|
||||||
|
- MH_AI_ENDPOINT=http://litellm:4000
|
||||||
|
- MH_AI_API_KEY=
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MarkdownHub</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3708
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "markdownhub-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.4.0",
|
||||||
|
"pinia": "^2.2.0",
|
||||||
|
"@milkdown/core": "^7.6.0",
|
||||||
|
"@milkdown/preset-commonmark": "^7.6.0",
|
||||||
|
"@milkdown/preset-gfm": "^7.6.0",
|
||||||
|
"@milkdown/plugin-collab": "^7.6.0",
|
||||||
|
"@milkdown/plugin-listener": "^7.6.0",
|
||||||
|
"@milkdown/theme-nord": "^7.6.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.0",
|
||||||
|
"@codemirror/state": "^6.5.0",
|
||||||
|
"@codemirror/view": "^6.35.0",
|
||||||
|
"codemirror": "^6.0.0",
|
||||||
|
"marked": "^12.0.0",
|
||||||
|
"yjs": "^13.6.0",
|
||||||
|
"y-websocket": "^2.0.0",
|
||||||
|
"y-codemirror.next": "^0.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app" v-if="authenticated">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>MarkdownHub</h2>
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<button @click="toggleTheme" title="Toggle theme">{{ dark ? '☀️' : '🌙' }}</button>
|
||||||
|
<button @click="createFolder" title="New folder">📁+</button>
|
||||||
|
<button @click="createFile" title="New file">📄+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<input v-model="searchQuery" placeholder="Search files..." @input="filterFiles" />
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<button :class="{active: view === 'files'}" @click="view = 'files'">📄 My Files</button>
|
||||||
|
<button :class="{active: view === 'shared'}" @click="view = 'shared'">🤝 Shared</button>
|
||||||
|
<button :class="{active: view === 'prefs'}" @click="view = 'prefs'">⚙️ Preferences</button>
|
||||||
|
<button v-if="isAdmin" :class="{active: view === 'admin'}" @click="view = 'admin'">👤 Admin</button>
|
||||||
|
</div>
|
||||||
|
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" />
|
||||||
|
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" />
|
||||||
|
</aside>
|
||||||
|
<main class="editor-area" v-if="view === 'files' || view === 'shared'">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="mode-switcher">
|
||||||
|
<button :class="{active: mode === 'wysiwyg'}" @click="mode = 'wysiwyg'">WYSIWYG</button>
|
||||||
|
<button :class="{active: mode === 'raw'}" @click="mode = 'raw'">Raw</button>
|
||||||
|
<button :class="{active: mode === 'split'}" @click="mode = 'split'">Split</button>
|
||||||
|
</div>
|
||||||
|
<div class="format-toolbar" v-if="currentFile">
|
||||||
|
<button @click="insertFormat('**', '**')" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||||
|
<button @click="insertFormat('*', '*')" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||||
|
<button @click="insertFormat('~~', '~~')" title="Strikethrough"><s>S</s></button>
|
||||||
|
<button @click="insertPrefix('# ')" title="H1">H1</button>
|
||||||
|
<button @click="insertPrefix('## ')" title="H2">H2</button>
|
||||||
|
<button @click="insertPrefix('### ')" title="H3">H3</button>
|
||||||
|
<button @click="insertPrefix('- ')" title="List">•</button>
|
||||||
|
<button @click="insertPrefix('1. ')" title="Numbered list">1.</button>
|
||||||
|
<button @click="insertPrefix('- [ ] ')" title="Checkbox">☐</button>
|
||||||
|
<button @click="insertPrefix('> ')" title="Quote">❝</button>
|
||||||
|
<button @click="insertFormat('`', '`')" title="Inline code (Ctrl+`)"></></button>
|
||||||
|
<button @click="insertCodeBlock()" title="Code block">```</button>
|
||||||
|
<button @click="insertFormat('[', '](url)')" title="Link (Ctrl+K)">🔗</button>
|
||||||
|
<button @click="insertPrefix('---\n')" title="Horizontal rule">―</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<span class="file-name">{{ currentFile || 'No file open' }}</span>
|
||||||
|
<span class="file-meta" v-if="fileMeta">{{ fileMeta }}</span>
|
||||||
|
<div class="export-actions" v-if="currentFile">
|
||||||
|
<button @click="exportPDF" title="Export PDF">PDF</button>
|
||||||
|
<button @click="exportHTML" title="Export HTML">HTML</button>
|
||||||
|
<button @click="exportMD" title="Download .md">MD</button>
|
||||||
|
</div>
|
||||||
|
<button class="save-btn" :class="{dirty: isDirty}" @click="saveFile">
|
||||||
|
{{ isDirty ? 'Save*' : 'Saved' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-container" :class="mode">
|
||||||
|
<div v-if="mode === 'raw' || mode === 'split'" class="raw-pane">
|
||||||
|
<textarea
|
||||||
|
ref="editor"
|
||||||
|
v-model="content"
|
||||||
|
@input="isDirty = true"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
placeholder="Start writing markdown..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="mode === 'wysiwyg'" class="wysiwyg-pane">
|
||||||
|
<MilkdownEditor
|
||||||
|
:key="currentFile"
|
||||||
|
v-model="content"
|
||||||
|
@update:modelValue="isDirty = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="mode === 'split'" class="preview-pane">
|
||||||
|
<div v-if="rendered" v-html="rendered"></div>
|
||||||
|
<p v-else style="color:#6c7086;font-style:italic">Preview will appear here...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- Preferences Panel -->
|
||||||
|
<main class="panel" v-if="view === 'prefs'">
|
||||||
|
<h2>Preferences</h2>
|
||||||
|
<div class="panel-section">
|
||||||
|
<label>Timezone</label>
|
||||||
|
<select v-model="prefs.timezone" @change="savePrefs">
|
||||||
|
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="panel-section">
|
||||||
|
<label>Default editor mode</label>
|
||||||
|
<select v-model="prefs.defaultMode" @change="savePrefs">
|
||||||
|
<option value="split">Split</option>
|
||||||
|
<option value="raw">Raw</option>
|
||||||
|
<option value="wysiwyg">WYSIWYG</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="panel-section">
|
||||||
|
<label>Theme</label>
|
||||||
|
<select v-model="prefs.theme" @change="applyThemeFromPrefs">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- Admin Panel -->
|
||||||
|
<main class="panel" v-if="view === 'admin' && isAdmin">
|
||||||
|
<h2>Admin — User Management</h2>
|
||||||
|
<div class="panel-section">
|
||||||
|
<h3>Create User</h3>
|
||||||
|
<form @submit.prevent="adminCreateUser" class="admin-form">
|
||||||
|
<input v-model="newUser.username" placeholder="Username" required />
|
||||||
|
<input v-model="newUser.email" placeholder="Email" required />
|
||||||
|
<input v-model="newUser.password" type="password" placeholder="Password" required />
|
||||||
|
<label><input type="checkbox" v-model="newUser.isAdmin" /> Admin</label>
|
||||||
|
<button type="submit">Create User</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="adminMsg" class="admin-msg">{{ adminMsg }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel-section">
|
||||||
|
<h3>Users</h3>
|
||||||
|
<table class="user-table">
|
||||||
|
<thead><tr><th>Username</th><th>Email</th><th>Admin</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.id">
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.email }}</td>
|
||||||
|
<td>{{ u.isAdmin ? '✓' : '' }}</td>
|
||||||
|
<td>{{ formatDate(u.createdAt) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div class="login" v-else>
|
||||||
|
<form @submit.prevent="login">
|
||||||
|
<h1>MarkdownHub</h1>
|
||||||
|
<input v-model="email" type="email" placeholder="Email" required />
|
||||||
|
<input v-model="password" type="password" placeholder="Password" required />
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
<p v-if="loginError" class="error">{{ loginError }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import FileTree from './components/FileTree.vue'
|
||||||
|
import MilkdownEditor from './components/MilkdownEditor.vue'
|
||||||
|
import { api, setToken } from './lib/api.js'
|
||||||
|
import { renderMarkdown } from './lib/markdown.js'
|
||||||
|
|
||||||
|
const authenticated = ref(false)
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loginError = ref('')
|
||||||
|
const token = ref('')
|
||||||
|
|
||||||
|
const fileTree = ref([])
|
||||||
|
const filteredFiles = ref([])
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const currentFile = ref('')
|
||||||
|
const content = ref('')
|
||||||
|
const isDirty = ref(false)
|
||||||
|
const mode = ref('split')
|
||||||
|
const editor = ref(null)
|
||||||
|
const dark = ref(true)
|
||||||
|
const view = ref('files')
|
||||||
|
const isAdmin = ref(false)
|
||||||
|
const fileMeta = ref('')
|
||||||
|
const sharedFiles = ref([])
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' })
|
||||||
|
const timezones = Intl.supportedValuesOf ? Intl.supportedValuesOf('timeZone') : ['UTC', 'Europe/Stockholm', 'America/New_York', 'Asia/Tokyo']
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
const users = ref([])
|
||||||
|
const newUser = ref({ username: '', email: '', password: '', isAdmin: false })
|
||||||
|
const adminMsg = ref('')
|
||||||
|
|
||||||
|
const rendered = computed(() => renderMarkdown(content.value))
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
dark.value = !dark.value
|
||||||
|
document.documentElement.setAttribute('data-theme', dark.value ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/auth/login', { email: email.value, password: password.value })
|
||||||
|
token.value = res.token
|
||||||
|
setToken(res.token)
|
||||||
|
authenticated.value = true
|
||||||
|
isAdmin.value = res.isAdmin
|
||||||
|
loginError.value = ''
|
||||||
|
loadFiles()
|
||||||
|
loadShared()
|
||||||
|
if (isAdmin.value) loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
loginError.value = 'Invalid credentials'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Files ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
fileTree.value = await api('/api/files/list', {})
|
||||||
|
filteredFiles.value = fileTree.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFiles() {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
filteredFiles.value = fileTree.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
filteredFiles.value = filterTree(fileTree.value, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTree(items, query) {
|
||||||
|
const result = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isDir) {
|
||||||
|
const children = filterTree(item.children || [], query)
|
||||||
|
if (children.length > 0) {
|
||||||
|
result.push({ ...item, children })
|
||||||
|
}
|
||||||
|
} else if (item.name.toLowerCase().includes(query)) {
|
||||||
|
result.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFile(path) {
|
||||||
|
if (isDirty.value && !confirm('Discard unsaved changes?')) return
|
||||||
|
const res = await api('/api/files/read', { path })
|
||||||
|
currentFile.value = path
|
||||||
|
content.value = res.content
|
||||||
|
fileMeta.value = res.created ? `Created: ${formatDate(res.created)}` : ''
|
||||||
|
isDirty.value = false
|
||||||
|
view.value = 'files'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFile() {
|
||||||
|
if (!currentFile.value) return
|
||||||
|
await api('/api/files/write', { path: currentFile.value, content: content.value })
|
||||||
|
isDirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFile() {
|
||||||
|
const name = prompt('File name (e.g. notes.md):')
|
||||||
|
if (!name) return
|
||||||
|
let path = name
|
||||||
|
if (!path.endsWith('.md') && !path.endsWith('.txt')) {
|
||||||
|
path += '.md'
|
||||||
|
}
|
||||||
|
await api('/api/files/create', { path, content: '' })
|
||||||
|
await loadFiles()
|
||||||
|
openFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFolder() {
|
||||||
|
const name = prompt('Folder name (e.g. projects):')
|
||||||
|
if (!name) return
|
||||||
|
await api('/api/files/create-folder', { path: name })
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(item) {
|
||||||
|
const msg = item.isDir
|
||||||
|
? `Delete folder "${item.name}" and all files in it?`
|
||||||
|
: `Delete "${item.name}"?`
|
||||||
|
if (!confirm(msg)) return
|
||||||
|
await api('/api/files/delete', { path: item.path })
|
||||||
|
if (currentFile.value === item.path) {
|
||||||
|
currentFile.value = ''
|
||||||
|
content.value = ''
|
||||||
|
isDirty.value = false
|
||||||
|
}
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadShared() {
|
||||||
|
try {
|
||||||
|
sharedFiles.value = await api('/api/files/shared', {})
|
||||||
|
} catch (e) {
|
||||||
|
sharedFiles.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Preferences ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function savePrefs() {
|
||||||
|
localStorage.setItem('mh_prefs', JSON.stringify(prefs.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeFromPrefs() {
|
||||||
|
dark.value = prefs.value.theme === 'dark'
|
||||||
|
document.documentElement.setAttribute('data-theme', prefs.value.theme)
|
||||||
|
savePrefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
users.value = await api('/api/users/list', {})
|
||||||
|
} catch (e) { /* not admin */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminCreateUser() {
|
||||||
|
try {
|
||||||
|
await api('/api/users/create', newUser.value)
|
||||||
|
adminMsg.value = `User "${newUser.value.username}" created`
|
||||||
|
newUser.value = { username: '', email: '', password: '', isAdmin: false }
|
||||||
|
loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
adminMsg.value = 'Failed to create user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
if (!d) return ''
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleString(undefined, { timeZone: prefs.value.timezone })
|
||||||
|
} catch { return d }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatting ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function insertFormat(before, after) {
|
||||||
|
const ta = editor.value
|
||||||
|
if (!ta) return
|
||||||
|
const start = ta.selectionStart
|
||||||
|
const end = ta.selectionEnd
|
||||||
|
const selected = content.value.substring(start, end)
|
||||||
|
const replacement = before + (selected || 'text') + after
|
||||||
|
content.value = content.value.substring(0, start) + replacement + content.value.substring(end)
|
||||||
|
isDirty.value = true
|
||||||
|
// Set cursor position after insert
|
||||||
|
const cursorPos = start + before.length + (selected ? selected.length : 4)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.focus()
|
||||||
|
ta.setSelectionRange(cursorPos, cursorPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPrefix(prefix) {
|
||||||
|
const ta = editor.value
|
||||||
|
if (!ta) return
|
||||||
|
const start = ta.selectionStart
|
||||||
|
// Find start of current line
|
||||||
|
const lineStart = content.value.lastIndexOf('\n', start - 1) + 1
|
||||||
|
content.value = content.value.substring(0, lineStart) + prefix + content.value.substring(lineStart)
|
||||||
|
isDirty.value = true
|
||||||
|
const cursorPos = start + prefix.length
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.focus()
|
||||||
|
ta.setSelectionRange(cursorPos, cursorPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCodeBlock() {
|
||||||
|
const ta = editor.value
|
||||||
|
if (!ta) return
|
||||||
|
const start = ta.selectionStart
|
||||||
|
const end = ta.selectionEnd
|
||||||
|
const selected = content.value.substring(start, end)
|
||||||
|
const block = '\n```\n' + (selected || '') + '\n```\n'
|
||||||
|
content.value = content.value.substring(0, start) + block + content.value.substring(end)
|
||||||
|
isDirty.value = true
|
||||||
|
const cursorPos = start + 5 + (selected ? selected.length : 0)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.focus()
|
||||||
|
ta.setSelectionRange(cursorPos, cursorPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keyboard Shortcuts ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleKeydown(e) {
|
||||||
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
|
if (ctrl && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
saveFile()
|
||||||
|
} else if (ctrl && e.key === 'b') {
|
||||||
|
e.preventDefault()
|
||||||
|
insertFormat('**', '**')
|
||||||
|
} else if (ctrl && e.key === 'i') {
|
||||||
|
e.preventDefault()
|
||||||
|
insertFormat('*', '*')
|
||||||
|
} else if (ctrl && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
insertFormat('[', '](url)')
|
||||||
|
} else if (ctrl && e.key === '`') {
|
||||||
|
e.preventDefault()
|
||||||
|
insertFormat('`', '`')
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
const ta = editor.value
|
||||||
|
const start = ta.selectionStart
|
||||||
|
content.value = content.value.substring(0, start) + ' ' + content.value.substring(ta.selectionEnd)
|
||||||
|
isDirty.value = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.setSelectionRange(start + 2, start + 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Export ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function exportMD() {
|
||||||
|
download(currentFile.value, content.value, 'text/markdown')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportHTML() {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>${currentFile.value}</title>
|
||||||
|
<style>body{font-family:-apple-system,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7}
|
||||||
|
code{background:#f0f0f0;padding:2px 6px;border-radius:3px}pre{background:#f0f0f0;padding:16px;border-radius:6px;overflow-x:auto}
|
||||||
|
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px 12px}th{background:#f5f5f5}
|
||||||
|
blockquote{border-left:4px solid #0969da;padding:0 16px;color:#656d76}</style>
|
||||||
|
</head><body>${rendered.value}</body></html>`
|
||||||
|
download(currentFile.value.replace(/\.md$/, '.html'), html, 'text/html')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPDF() {
|
||||||
|
const win = window.open('', '_blank')
|
||||||
|
win.document.write(`<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>${currentFile.value}</title>
|
||||||
|
<style>body{font-family:-apple-system,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7}
|
||||||
|
code{background:#f0f0f0;padding:2px 6px;border-radius:3px}pre{background:#f0f0f0;padding:16px;border-radius:6px;overflow-x:auto}
|
||||||
|
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px 12px}th{background:#f5f5f5}
|
||||||
|
blockquote{border-left:4px solid #0969da;padding:0 16px;color:#656d76}</style>
|
||||||
|
</head><body>${rendered.value}</body></html>`)
|
||||||
|
win.document.close()
|
||||||
|
setTimeout(() => { win.print() }, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(filename, content, mime) {
|
||||||
|
const blob = new Blob([content], { type: mime })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(a.href)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||||
|
|
||||||
|
:root, [data-theme="dark"] {
|
||||||
|
--bg-primary: #1e1e2e;
|
||||||
|
--bg-secondary: #181825;
|
||||||
|
--bg-tertiary: #11111b;
|
||||||
|
--bg-hover: #313244;
|
||||||
|
--bg-selected: #45475a;
|
||||||
|
--border: #313244;
|
||||||
|
--text: #cdd6f4;
|
||||||
|
--text-muted: #a6adc8;
|
||||||
|
--accent: #89b4fa;
|
||||||
|
--success: #a6e3a1;
|
||||||
|
--danger: #f38ba8;
|
||||||
|
--code-bg: #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f5f5f5;
|
||||||
|
--bg-tertiary: #fafafa;
|
||||||
|
--bg-hover: #e8e8e8;
|
||||||
|
--bg-selected: #d0d0d0;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--text: #1e1e2e;
|
||||||
|
--text-muted: #656d76;
|
||||||
|
--accent: #0969da;
|
||||||
|
--success: #1a7f37;
|
||||||
|
--danger: #cf222e;
|
||||||
|
--code-bg: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 { font-size: 16px; }
|
||||||
|
.sidebar-header button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sidebar-actions { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.search-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.search-box input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ─── Toolbar ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.editor-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switcher button {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mode-switcher button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.mode-switcher button:first-child { border-radius: 4px 0 0 4px; }
|
||||||
|
.mode-switcher button:last-child { border-radius: 0 4px 4px 0; }
|
||||||
|
|
||||||
|
.format-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.format-toolbar button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
.format-toolbar button:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name { color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.export-actions { display: flex; gap: 4px; }
|
||||||
|
.export-actions button {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.export-actions button:hover { background: var(--bg-selected); }
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.save-btn.dirty { background: var(--danger); }
|
||||||
|
|
||||||
|
/* ─── Editor ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-container.raw .raw-pane { flex: 1; }
|
||||||
|
.editor-container.wysiwyg .wysiwyg-pane { flex: 1; }
|
||||||
|
.editor-container.split .raw-pane,
|
||||||
|
.editor-container.split .preview-pane { flex: 1; }
|
||||||
|
.editor-container.split .raw-pane { border-right: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.wysiwyg-pane {
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-pane textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Preview ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.preview-pane h1 { font-size: 2em; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||||
|
.preview-pane h2 { font-size: 1.5em; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||||
|
.preview-pane h3 { font-size: 1.25em; margin: 1em 0; color: var(--text); }
|
||||||
|
.preview-pane h4, .preview-pane h5, .preview-pane h6 { margin: 1em 0; color: var(--text-muted); }
|
||||||
|
.preview-pane p { margin: 0 0 16px; }
|
||||||
|
.preview-pane code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; font-size: 85%; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||||||
|
.preview-pane pre { background: var(--code-bg); padding: 16px; border-radius: 6px; overflow-x: auto; margin: 0 0 16px; }
|
||||||
|
.preview-pane pre code { background: none; padding: 0; font-size: 85%; }
|
||||||
|
.preview-pane ul, .preview-pane ol { padding-left: 2em; margin: 0 0 16px; }
|
||||||
|
.preview-pane li { margin: 4px 0; }
|
||||||
|
.preview-pane blockquote { border-left: 4px solid var(--accent); padding: 0 16px; color: var(--text-muted); margin: 0 0 16px; }
|
||||||
|
.preview-pane table { border-collapse: collapse; width: 100%; margin: 0 0 16px; }
|
||||||
|
.preview-pane th, .preview-pane td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
|
||||||
|
.preview-pane th { background: var(--code-bg); }
|
||||||
|
.preview-pane hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
|
||||||
|
.preview-pane a { color: var(--accent); text-decoration: none; }
|
||||||
|
.preview-pane a:hover { text-decoration: underline; }
|
||||||
|
.preview-pane img { max-width: 100%; border-radius: 4px; }
|
||||||
|
.preview-pane input[type="checkbox"] { margin-right: 6px; }
|
||||||
|
|
||||||
|
/* ─── Login ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.login {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.login form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
.login h1 { color: var(--text); text-align: center; margin-bottom: 12px; }
|
||||||
|
.login input {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.login button {
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.login .error { color: var(--danger); text-align: center; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ─── Sidebar Nav ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sidebar-nav button {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sidebar-nav button.active { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.sidebar-nav button:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
/* ─── Panels ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.panel h2 { margin-bottom: 24px; }
|
||||||
|
.panel h3 { margin: 16px 0 8px; }
|
||||||
|
.panel-section { margin-bottom: 24px; }
|
||||||
|
.panel-section label { display: block; margin-bottom: 6px; color: var(--text-muted); font-size: 13px; }
|
||||||
|
.panel-section select, .panel-section input[type="text"] {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.admin-form input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.admin-form button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-msg { margin-top: 8px; color: var(--success); font-size: 13px; }
|
||||||
|
|
||||||
|
.user-table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||||
|
.user-table th, .user-table td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; font-size: 13px; }
|
||||||
|
.user-table th { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.file-meta { color: var(--text-muted); font-size: 11px; }
|
||||||
|
|
||||||
|
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar { position: fixed; left: -260px; z-index: 10; transition: left 0.2s; }
|
||||||
|
.sidebar.open { left: 0; }
|
||||||
|
.format-toolbar { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-tree">
|
||||||
|
<div v-for="item in files" :key="item.path" class="tree-item">
|
||||||
|
<div
|
||||||
|
class="tree-node"
|
||||||
|
:class="{ selected: item.path === selected, folder: item.isDir }"
|
||||||
|
@click="item.isDir ? toggleFolder(item) : $emit('select', item.path)"
|
||||||
|
@contextmenu.prevent="showContext($event, item)"
|
||||||
|
>
|
||||||
|
<span class="icon">{{ item.isDir ? (expanded[item.path] ? '📂' : '📁') : '📄' }}</span>
|
||||||
|
<span class="name">{{ item.name }}</span>
|
||||||
|
<button class="delete-btn" @click.stop="$emit('delete', item)" title="Delete">×</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.isDir && expanded[item.path] && item.children" class="children">
|
||||||
|
<FileTree :files="item.children" :selected="selected" @select="$emit('select', $event)" @delete="$emit('delete', $event)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
files: { type: Array, default: () => [] },
|
||||||
|
selected: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['select', 'delete'])
|
||||||
|
|
||||||
|
const expanded = reactive({})
|
||||||
|
|
||||||
|
function toggleFolder(item) {
|
||||||
|
expanded[item.path] = !expanded[item.path]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-tree { padding: 8px; overflow-y: auto; flex: 1; }
|
||||||
|
.tree-item { user-select: none; }
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tree-node:hover { background: var(--hover-bg, #313244); }
|
||||||
|
.tree-node.selected { background: var(--selected-bg, #45475a); }
|
||||||
|
.icon { font-size: 14px; }
|
||||||
|
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.delete-btn {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #f38ba8;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.tree-node:hover .delete-btn { display: block; }
|
||||||
|
.children { padding-left: 16px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="editorRoot" class="milkdown-editor"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core'
|
||||||
|
import { commonmark } from '@milkdown/preset-commonmark'
|
||||||
|
import { gfm } from '@milkdown/preset-gfm'
|
||||||
|
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||||
|
import { nord } from '@milkdown/theme-nord'
|
||||||
|
import '@milkdown/theme-nord/style.css'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const editorRoot = ref(null)
|
||||||
|
let editorInstance = null
|
||||||
|
let suppressUpdate = false
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
editorInstance = await Editor.make()
|
||||||
|
.config((ctx) => {
|
||||||
|
ctx.set(rootCtx, editorRoot.value)
|
||||||
|
ctx.set(defaultValueCtx, props.modelValue)
|
||||||
|
ctx.get(listenerCtx).markdownUpdated((ctx, markdown, prevMarkdown) => {
|
||||||
|
if (markdown !== prevMarkdown) {
|
||||||
|
suppressUpdate = true
|
||||||
|
emit('update:modelValue', markdown)
|
||||||
|
setTimeout(() => { suppressUpdate = false }, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.config(nord)
|
||||||
|
.use(commonmark)
|
||||||
|
.use(gfm)
|
||||||
|
.use(listener)
|
||||||
|
.create()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (suppressUpdate || !editorInstance) return
|
||||||
|
// Only update if content actually differs (avoid cursor jump)
|
||||||
|
// For now, we don't force-update the editor from outside
|
||||||
|
// since the user is typing in it directly
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (editorInstance) {
|
||||||
|
editorInstance.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.milkdown-editor {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.milkdown-editor .milkdown {
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.milkdown-editor .milkdown p { margin: 0 0 16px; }
|
||||||
|
.milkdown-editor .milkdown h1 { font-size: 2em; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid #313244; }
|
||||||
|
.milkdown-editor .milkdown h2 { font-size: 1.5em; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid #313244; }
|
||||||
|
.milkdown-editor .milkdown h3 { font-size: 1.25em; margin: 1em 0; }
|
||||||
|
.milkdown-editor .milkdown code { background: #313244; padding: 2px 6px; border-radius: 3px; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.milkdown-editor .milkdown pre { background: #313244; padding: 16px; border-radius: 6px; overflow-x: auto; margin: 0 0 16px; }
|
||||||
|
.milkdown-editor .milkdown blockquote { border-left: 4px solid #89b4fa; padding: 0 16px; color: #a6adc8; margin: 0 0 16px; }
|
||||||
|
.milkdown-editor .milkdown ul, .milkdown-editor .milkdown ol { padding-left: 2em; margin: 0 0 16px; }
|
||||||
|
.milkdown-editor .milkdown table { border-collapse: collapse; width: 100%; margin: 0 0 16px; }
|
||||||
|
.milkdown-editor .milkdown th, .milkdown-editor .milkdown td { border: 1px solid #45475a; padding: 8px 12px; }
|
||||||
|
.milkdown-editor .milkdown th { background: #313244; }
|
||||||
|
.milkdown-editor .milkdown hr { border: none; border-top: 1px solid #45475a; margin: 24px 0; }
|
||||||
|
.milkdown-editor .milkdown a { color: #89b4fa; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
let authToken = ''
|
||||||
|
|
||||||
|
export function setToken(token) {
|
||||||
|
authToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api(path, body) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' }
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API error: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
export function renderMarkdown(md) {
|
||||||
|
if (!md) return ''
|
||||||
|
return marked(md, { gfm: true, breaks: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
'/ws': { target: 'ws://localhost:8080', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module markdownhub
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
golang.org/x/crypto v0.32.0
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -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