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:
31
backend/cmd/server/main.go
Normal file
31
backend/cmd/server/main.go
Normal 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
8
backend/go.mod
Normal 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
|
||||
)
|
||||
111
backend/internal/auth/auth.go
Normal file
111
backend/internal/auth/auth.go
Normal 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
|
||||
}
|
||||
61
backend/internal/config/config.go
Normal file
61
backend/internal/config/config.go
Normal 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
104
backend/internal/git/git.go
Normal 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
|
||||
}
|
||||
16
backend/internal/graph/resolver.go
Normal file
16
backend/internal/graph/resolver.go
Normal 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.
|
||||
87
backend/internal/graph/schema.graphql
Normal file
87
backend/internal/graph/schema.graphql
Normal 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!
|
||||
}
|
||||
28
backend/internal/graph/server.go
Normal file
28
backend/internal/graph/server.go
Normal 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
|
||||
}
|
||||
70
backend/internal/storage/storage.go
Normal file
70
backend/internal/storage/storage.go
Normal 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")
|
||||
Reference in New Issue
Block a user