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:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
Reference in New Issue
Block a user