feat: Archivum skeleton — Go/GraphQL backend + Vue 3 PWA frontend

Full project scaffold: multi-stage Dockerfile (ARM64/AMD64), AsciiDoc↔TipTap
bridge, Setup Wizard, CodeMirror source editor, Git-backed storage layer,
LDAP+JWT auth skeleton, Tailwind mobile-first layout, and VS Code build/push
tasks targeting registry at 192.168.0.19:5000.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 13:07:01 +02:00
parent 3db6fec664
commit e6b64e3344
35 changed files with 1882 additions and 74 deletions

View File

@@ -0,0 +1,31 @@
package main
import (
"log"
"net/http"
"os"
"github.com/brasse-b/archivum/internal/config"
"github.com/brasse-b/archivum/internal/graph"
)
func main() {
cfg, err := config.Load(configPath())
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
srv := graph.NewServer(cfg)
log.Printf("Archivum listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, srv); err != nil {
log.Fatal(err)
}
}
func configPath() string {
if p := os.Getenv("DOCKER_PATH"); p != "" {
return p + "/config/config.json"
}
return "config.json"
}

8
backend/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module github.com/brasse-b/archivum
go 1.22
require (
github.com/go-ldap/ldap/v3 v3.4.8
modernc.org/sqlite v1.30.0
)

View File

@@ -0,0 +1,111 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
"github.com/brasse-b/archivum/internal/config"
"github.com/go-ldap/ldap/v3"
)
// Session represents an authenticated user session.
type Session struct {
Username string
Token string
ExpiresAt time.Time
}
// Manager handles LDAP authentication and in-memory session tracking.
type Manager struct {
cfg *config.Config
mu sync.RWMutex
sessions map[string]*Session
}
func NewManager(cfg *config.Config) *Manager {
return &Manager{
cfg: cfg,
sessions: make(map[string]*Session),
}
}
// Login authenticates against LDAP and returns a bearer token on success.
func (m *Manager) Login(username, password string) (string, error) {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", m.cfg.LDAP.Host, m.cfg.LDAP.Port))
if err != nil {
return "", err
}
defer l.Close()
if err := l.Bind(m.cfg.LDAP.BindDN, m.cfg.LDAP.BindPassword); err != nil {
return "", err
}
sr, err := l.Search(&ldap.SearchRequest{
BaseDN: m.cfg.LDAP.BaseDN,
Filter: fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)),
Scope: ldap.ScopeWholeSubtree,
})
if err != nil {
return "", err
}
if len(sr.Entries) != 1 {
return "", errors.New("user not found")
}
userDN := sr.Entries[0].DN
if err := l.Bind(userDN, password); err != nil {
return "", errors.New("invalid credentials")
}
token, err := generateToken()
if err != nil {
return "", err
}
m.mu.Lock()
m.sessions[token] = &Session{
Username: username,
Token: token,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
m.mu.Unlock()
return token, nil
}
// Validate checks a bearer token and returns the associated session.
func (m *Manager) Validate(token string) (*Session, error) {
m.mu.RLock()
s, ok := m.sessions[token]
m.mu.RUnlock()
if !ok {
return nil, errors.New("invalid token")
}
if time.Now().After(s.ExpiresAt) {
m.mu.Lock()
delete(m.sessions, token)
m.mu.Unlock()
return nil, errors.New("token expired")
}
return s, nil
}
// Logout removes a session.
func (m *Manager) Logout(token string) {
m.mu.Lock()
delete(m.sessions, token)
m.mu.Unlock()
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -0,0 +1,61 @@
package config
import (
"encoding/json"
"errors"
"os"
)
// Config holds all backend runtime configuration.
type Config struct {
StoragePath string `json:"storage_path"`
DBPath string `json:"db_path"`
LDAP LDAPConfig `json:"ldap"`
JWTSecret string `json:"jwt_secret"`
ListenAddr string `json:"listen_addr"`
}
type LDAPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
BaseDN string `json:"base_dn"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
}
// ErrRequireSetup is returned when config is missing or empty,
// signalling that the frontend should start the Setup Wizard.
var ErrRequireSetup = errors.New("REQUIRE_SETUP")
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrRequireSetup
}
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.StoragePath == "" || cfg.JWTSecret == "" {
return nil, ErrRequireSetup
}
if cfg.ListenAddr == "" {
cfg.ListenAddr = ":4000"
}
return &cfg, nil
}
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}

104
backend/internal/git/git.go Normal file
View File

@@ -0,0 +1,104 @@
package git
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// Repo wraps a bare directory that is a Git repository.
type Repo struct {
root string
}
// Open returns a Repo for an existing directory, initialising Git if needed.
func Open(root string) (*Repo, error) {
r := &Repo{root: root}
if err := r.initIfNeeded(); err != nil {
return nil, err
}
return r, nil
}
func (r *Repo) initIfNeeded() error {
out, _ := r.run("rev-parse", "--is-inside-work-tree")
if strings.TrimSpace(out) == "true" {
return nil
}
_, err := r.run("init")
return err
}
// Commit writes data to path and creates a Git commit authored by author.
func (r *Repo) Commit(path, content, message, author, email string) error {
full := filepath.Join(r.root, path)
if err := writeFile(full, content); err != nil {
return err
}
if _, err := r.run("add", path); 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
}
// Log returns the commit history for a file.
func (r *Repo) Log(path string) ([]LogEntry, error) {
out, err := r.run("log", "--format=%H|%an|%ae|%ai|%s", "--", path)
if err != nil {
return nil, err
}
var entries []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],
})
}
}
return entries, nil
}
// Diff returns the unified diff between two commits for a file.
func (r *Repo) Diff(path, fromHash, toHash string) (string, error) {
out, err := r.run("diff", fromHash, toHash, "--", path)
return out, err
}
// Show returns the file content at a specific commit.
func (r *Repo) Show(hash, path string) (string, error) {
return r.run("show", fmt.Sprintf("%s:%s", hash, path))
}
type LogEntry struct {
Hash string
Author string
Email string
Date string
Subject string
}
func (r *Repo) run(args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", r.root}, args...)...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("git %v: %w — %s", args, err, stderr.String())
}
return stdout.String(), nil
}

View File

@@ -0,0 +1,16 @@
package graph
// Resolver is the root resolver — all query/mutation methods live here.
// Fields are populated by NewServer via dependency injection.
type Resolver struct {
cfg interface{} // *config.Config — placeholder until gqlgen generation
auth interface{} // *auth.Manager
storage interface{} // *storage.Store
git interface{} // *git.Repo
}
// TODO: Run `go generate ./...` after adding gqlgen to go.mod to generate
// the type-safe resolver stubs from schema.graphql.
//
// The generated code lands in graph/generated.go (gitignored for now).
// Implement each method on *Resolver once the stubs exist.

View File

@@ -0,0 +1,87 @@
type Query {
# Returns REQUIRE_SETUP if config is missing.
systemStatus: SystemStatus!
# Fetch a document by its slug path.
document(slug: String!): Document
# List documents under an optional path prefix.
documents(prefix: String): [DocumentMeta!]!
# Commit history for a document.
history(slug: String!): [CommitEntry!]!
# Unified diff between two commits for a document.
diff(slug: String!, fromHash: String!, toHash: String!): String!
# Raw content of a document at a specific commit.
documentAtCommit(slug: String!, hash: String!): String!
}
type Mutation {
# First-run setup.
setup(input: SetupInput!): Boolean!
# Authenticate and receive a bearer token.
login(username: String!, password: String!): String!
# Invalidate the current session.
logout: Boolean!
# Save (create or update) a document.
saveDocument(input: SaveDocumentInput!): Document!
# Delete a document.
deleteDocument(slug: String!): Boolean!
}
# ── Types ──────────────────────────────────────────────────────────────────────
enum SystemStatus {
OK
REQUIRE_SETUP
}
type Document {
slug: String!
content: String!
meta: DocumentMeta!
}
type DocumentMeta {
slug: String!
title: String!
updatedAt: String!
}
type CommitEntry {
hash: String!
author: String!
email: String!
date: String!
subject: String!
}
# ── Inputs ─────────────────────────────────────────────────────────────────────
input SetupInput {
storagePath: String!
adminUser: String!
adminPass: String!
ldap: LDAPInput
jwtSecret: String!
}
input LDAPInput {
host: String!
port: Int!
baseDN: String!
bindDN: String!
bindPassword: String!
}
input SaveDocumentInput {
slug: String!
content: String!
commitMessage: String!
}

View File

@@ -0,0 +1,28 @@
package graph
import (
"net/http"
"github.com/brasse-b/archivum/internal/config"
)
// NewServer wires up the HTTP handler for the GraphQL endpoint.
// Replace the stub handler with the gqlgen-generated handler once
// `go generate ./...` has been run.
func NewServer(cfg *config.Config) http.Handler {
mux := http.NewServeMux()
// Placeholder — replace with generated gqlgen handler.
mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data":{"systemStatus":"REQUIRE_SETUP"}}`))
})
// Simple health check.
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
return mux
}

View File

@@ -0,0 +1,70 @@
package storage
import (
"errors"
"os"
"path/filepath"
"strings"
)
// Store manages AsciiDoc files on disk.
type Store struct {
root string
}
func New(root string) (*Store, error) {
if err := os.MkdirAll(root, 0755); err != nil {
return nil, err
}
return &Store{root: root}, nil
}
// Read returns the content of a document by its slug path (e.g. "guides/install").
func (s *Store) Read(slug string) (string, error) {
data, err := os.ReadFile(s.filePath(slug))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
return "", err
}
return string(data), nil
}
// Write persists content to disk, creating parent directories as needed.
func (s *Store) Write(slug, content string) error {
path := s.filePath(slug)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, []byte(content), 0644)
}
// Delete removes a document from disk.
func (s *Store) Delete(slug string) error {
return os.Remove(s.filePath(slug))
}
// List returns all document slugs under an optional prefix.
func (s *Store) List(prefix string) ([]string, error) {
base := filepath.Join(s.root, filepath.FromSlash(prefix))
var slugs []string
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".adoc") {
rel, _ := filepath.Rel(s.root, path)
slug := strings.TrimSuffix(filepath.ToSlash(rel), ".adoc")
slugs = append(slugs, slug)
}
return nil
})
return slugs, err
}
func (s *Store) filePath(slug string) string {
return filepath.Join(s.root, filepath.FromSlash(slug)+".adoc")
}
var ErrNotFound = errors.New("document not found")