Implement AsciiDoc and TipTap conversion logic, including new Admonition node and CodeBlock extension; add DiffViewer and HistoryPanel components for document version comparison; introduce password hashing utility with SHA-256.

This commit is contained in:
2026-04-12 22:28:15 +02:00
parent 05b773c14c
commit 376b946e73
22 changed files with 2767 additions and 323 deletions

View File

@@ -57,6 +57,13 @@ func (d *DB) init() error {
return err
}
// HasUsers returns true if at least one user account exists.
func (d *DB) HasUsers() bool {
var n int
d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n > 0
}
// CreateUser inserts a new user. Returns ErrUserExists if the username is taken.
func (d *DB) CreateUser(username, passHash, role string) error {
_, err := d.sql.Exec(

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
@@ -49,28 +50,43 @@ func (r *Repo) Commit(path, content, message, author, email string) error {
return err
}
// Log returns the commit history for a file.
// Log returns the commit history for a file, including added/removed line counts.
func (r *Repo) Log(path string) ([]LogEntry, error) {
out, err := r.run("log", "--format=%H|%an|%ae|%ai|%s", "--", path)
out, err := r.run("log", "--format=COMMIT|%H|%an|%ae|%ai|%s", "--numstat", "--", path)
if err != nil {
return nil, err
}
var entries []LogEntry
var current *LogEntry
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "|", 5)
if len(parts) == 5 {
entries = append(entries, LogEntry{
Hash: parts[0],
Author: parts[1],
Email: parts[2],
Date: parts[3],
Subject: parts[4],
})
if strings.HasPrefix(line, "COMMIT|") {
if current != nil {
entries = append(entries, *current)
}
parts := strings.SplitN(strings.TrimPrefix(line, "COMMIT|"), "|", 5)
if len(parts) == 5 {
current = &LogEntry{
Hash: parts[0],
Author: parts[1],
Email: parts[2],
Date: parts[3],
Subject: parts[4],
}
}
} else if current != nil && strings.Contains(line, "\t") {
// numstat line: "added\tremoved\tfilename"
fields := strings.SplitN(line, "\t", 3)
if len(fields) == 3 {
added, _ := strconv.Atoi(fields[0])
removed, _ := strconv.Atoi(fields[1])
current.Added += added
current.Removed += removed
}
}
}
if current != nil {
entries = append(entries, *current)
}
return entries, nil
}
@@ -85,12 +101,34 @@ func (r *Repo) Show(hash, path string) (string, error) {
return r.run("show", fmt.Sprintf("%s:%s", hash, path))
}
// HasUncommitted returns true if the working tree contains untracked or
// modified files that have not yet been committed.
func (r *Repo) HasUncommitted() bool {
out, err := r.run("status", "--porcelain")
return err == nil && strings.TrimSpace(out) != ""
}
// CommitAll stages every file in the repo root and creates a commit.
func (r *Repo) CommitAll(message, author, email string) error {
if _, err := r.run("add", "-A"); err != nil {
return err
}
_, err := r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "-m", message,
)
return err
}
type LogEntry struct {
Hash string
Author string
Email string
Date string
Subject string
Added int
Removed int
}
func writeFile(path, content string) error {

View File

@@ -22,6 +22,9 @@ type Query {
# Raw content of a document at a specific commit.
documentAtCommit(slug: String!, hash: String!): String!
# Whether the current storage path has uncommitted files.
repoStatus: RepoStatus!
}
type Mutation {
@@ -51,6 +54,9 @@ type Mutation {
# Change the current user's password.
changePassword(old: String!, new: String!): Boolean!
# Commit all uncommitted files in the storage path.
initCommit(message: String!): Boolean!
}
# ── Types ──────────────────────────────────────────────────────────────────────
@@ -100,6 +106,12 @@ type CommitEntry {
email: String!
date: String!
subject: String!
added: Int!
removed: Int!
}
type RepoStatus {
hasUncommitted: Boolean!
}
# ── Inputs ─────────────────────────────────────────────────────────────────────

View File

@@ -64,6 +64,11 @@ func (s *Server) initRuntime(cfg *config.Config) {
}
var d *db.DB
if dir := filepath.Dir(cfg.DBPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("[db] failed to create directory %s: %v", dir, err)
}
}
if database, err := db.New(cfg.DBPath); err != nil {
log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err)
} else {
@@ -127,8 +132,11 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
cfg := s.cfg
store := s.store
database := s.database
s.mu.RUnlock()
needsSetup := cfg == nil || database == nil || !database.HasUsers()
q := req.Query
switch {
@@ -137,7 +145,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
s.handleSetup(w, req)
case strings.Contains(q, "systemStatus"):
if cfg == nil {
if needsSetup {
writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`)
} else {
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
@@ -155,7 +163,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
// ── All others require initialised config ─────────────────────────────────
default:
if cfg == nil {
if needsSetup {
writeGQLError(w, "REQUIRE_SETUP")
return
}
@@ -201,17 +209,23 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "config"):
s.handleConfig(w, sess)
case strings.Contains(q, "history"):
s.handleHistory(w, req, sess)
case strings.Contains(q, "repoStatus"):
s.handleRepoStatus(w, sess)
case strings.Contains(q, "diff"):
s.handleDiff(w, req, sess)
case strings.Contains(q, "initCommit"):
s.handleInitCommit(w, req, sess)
case strings.Contains(q, "documentAtCommit"):
s.handleDocumentAtCommit(w, req, sess)
case strings.Contains(q, "history"):
s.handleHistory(w, req, sess)
case strings.Contains(q, "documents") || strings.Contains(q, "document"):
s.handleDocuments(w, req, sess, store)
case strings.Contains(q, "diff"):
s.handleDiff(w, req, sess)
case strings.Contains(q, "documentAtCommit"):
s.handleDocumentAtCommit(w, req, sess)
case strings.Contains(q, "documents") || strings.Contains(q, "document"):
s.handleDocuments(w, req, sess, store)
default:
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
@@ -826,6 +840,63 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
})
}
func (s *Server) handleRepoStatus(w http.ResponseWriter, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
s.mu.RLock()
repo := s.gitRepo
s.mu.RUnlock()
hasUncommitted := repo != nil && repo.HasUncommitted()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"repoStatus": map[string]interface{}{
"hasUncommitted": hasUncommitted,
},
},
})
}
func (s *Server) handleInitCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
message, _ := req.Variables["message"].(string)
if message == "" {
message = "Initial commit"
}
s.mu.RLock()
repo := s.gitRepo
s.mu.RUnlock()
if repo == nil {
writeGQLError(w, "storage not initialised")
return
}
if !repo.HasUncommitted() {
// Nothing to commit — return success without error.
writeJSON(w, `{"data":{"initCommit":true}}`)
return
}
email := sess.Username + "@archivum"
if err := repo.CommitAll(message, sess.Username, email); err != nil {
log.Printf("[git] initCommit failed for %s: %v", sess.Username, err)
writeGQLError(w, fmt.Sprintf("commit failed: %v", err))
return
}
log.Printf("[git] initCommit by %s: %q", sess.Username, message)
writeJSON(w, `{"data":{"initCommit":true}}`)
}
func (s *Server) handleUpdateStoragePath(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
log.Printf("[unauth] session is nil")
@@ -886,7 +957,7 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
cfg := &config.Config{
StoragePath: storagePath,
DBPath: "/data/db/archivum.db",
DBPath: resolveDBPath(s.configPath),
JWTSecret: jwtSecret,
ListenAddr: ":4000",
}
@@ -905,19 +976,8 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
}
}
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err))
return
}
if err := config.Save(s.configPath, cfg); err != nil {
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
return
}
log.Printf("[setup] config written to %s", s.configPath)
// Open database and create admin user.
// Open database and create admin user BEFORE saving config,
// so a failed DB creation does not leave a broken config.json on disk.
if err := os.MkdirAll(dirOf(cfg.DBPath), 0755); err != nil {
writeGQLError(w, fmt.Sprintf("could not create db directory: %v", err))
return
@@ -942,6 +1002,18 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
log.Printf("[setup] admin user %q created", adminUser)
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err))
return
}
if err := config.Save(s.configPath, cfg); err != nil {
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
return
}
log.Printf("[setup] config written to %s", s.configPath)
store, err := storage.New(storagePath)
if err != nil {
log.Printf("[setup] failed to open storage at %s: %v", storagePath, err)
@@ -1144,6 +1216,17 @@ func dirOf(path string) string {
return path[:idx]
}
// resolveDBPath returns the best DB path given the config file location.
// Prefers /data/db/archivum.db (Docker volume) if /data is writable;
// otherwise places the DB next to the config file.
func resolveDBPath(configPath string) string {
const dockerDB = "/data/db/archivum.db"
if err := os.MkdirAll("/data/db", 0755); err == nil {
return dockerDB
}
return filepath.Join(dirOf(configPath), "archivum.db")
}
func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
@@ -1174,6 +1257,8 @@ func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth
"email": entry.Email,
"date": entry.Date,
"subject": entry.Subject,
"added": entry.Added,
"removed": entry.Removed,
})
}
writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "history": out } })