feat: bootstrap foundation application

This commit is contained in:
2026-04-29 00:26:58 -04:00
parent 8e6646499f
commit 4a72e1e030
53 changed files with 4443 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/tim/md-hub-secure/apps/server/internal/app"
"github.com/tim/md-hub-secure/apps/server/internal/config"
"github.com/tim/md-hub-secure/apps/server/internal/logging"
)
func main() {
cfg, err := config.Load()
if err != nil {
slog.Error("load config", "error", err)
os.Exit(1)
}
logger := logging.New(cfg.LogLevel)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
application, err := app.New(ctx, cfg, logger)
if err != nil {
logger.Error("initialize application", "error", err)
os.Exit(1)
}
defer func() {
if closeErr := application.Close(); closeErr != nil {
logger.Error("close application", "error", closeErr)
}
}()
err = application.Run(ctx)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("run application", "error", err)
os.Exit(1)
}
}

15
apps/server/go.mod Normal file
View File

@@ -0,0 +1,15 @@
module github.com/tim/md-hub-secure/apps/server
go 1.24.2
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
github.com/yuin/goldmark v1.8.2
)
require (
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
)

20
apps/server/go.sum Normal file
View File

@@ -0,0 +1,20 @@
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -0,0 +1,98 @@
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/tim/md-hub-secure/apps/server/internal/config"
"github.com/tim/md-hub-secure/apps/server/internal/database"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
"github.com/tim/md-hub-secure/apps/server/internal/httpserver"
"github.com/tim/md-hub-secure/apps/server/internal/markdown"
"github.com/tim/md-hub-secure/apps/server/internal/store"
)
type App struct {
cfg config.Config
logger *slog.Logger
db database.DB
server *http.Server
}
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
if err := os.MkdirAll(cfg.Content.SourceDir, 0o755); err != nil {
return nil, fmt.Errorf("create content source dir: %w", err)
}
contentStore, err := store.New(cfg.Content.StoreDir)
if err != nil {
return nil, fmt.Errorf("create content store: %w", err)
}
db, err := database.Open(ctx, cfg.Database)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
return nil, fmt.Errorf("apply migrations: %w", err)
}
renderer := markdown.NewRenderer()
repo := docs.NewRepository(db.SQL())
service := docs.NewService(cfg.Content.SourceDir, contentStore, renderer, repo, logger)
if err := service.SyncSourceDir(ctx); err != nil {
logger.Warn("initial content sync failed", "error", err)
}
handler, err := httpserver.New(httpserver.Dependencies{
Config: cfg,
Logger: logger,
Documents: service,
Repository: repo,
ContentStore: contentStore,
})
if err != nil {
return nil, fmt.Errorf("build http handler: %w", err)
}
server := &http.Server{
Addr: cfg.Server.Addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return &App{
cfg: cfg,
logger: logger,
db: db,
server: server,
}, nil
}
func (a *App) Run(ctx context.Context) error {
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := a.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
a.logger.Error("shutdown server", "error", err)
}
}()
a.logger.Info("starting server", "addr", a.cfg.Server.Addr)
return a.server.ListenAndServe()
}
func (a *App) Close() error {
return a.db.Close()
}

View File

@@ -0,0 +1,99 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type Config struct {
Server ServerConfig `json:"server"`
Database DatabaseConfig `json:"database"`
Content ContentConfig `json:"content"`
Web WebConfig `json:"web"`
LogLevel string `json:"logLevel"`
}
type ServerConfig struct {
Addr string `json:"addr"`
}
type DatabaseConfig struct {
Path string `json:"path"`
PrimaryURL string `json:"primaryUrl"`
AuthToken string `json:"authToken"`
}
type ContentConfig struct {
SourceDir string `json:"sourceDir"`
StoreDir string `json:"storeDir"`
}
type WebConfig struct {
DistDir string `json:"distDir"`
}
func Default() Config {
return Config{
Server: ServerConfig{
Addr: ":8080",
},
Database: DatabaseConfig{
Path: filepath.Join("..", "..", "data", "db.sqlite"),
},
Content: ContentConfig{
SourceDir: filepath.Join("..", "..", "content"),
StoreDir: filepath.Join("..", "..", "data", "files"),
},
Web: WebConfig{
DistDir: filepath.Join("..", "web", "dist"),
},
LogLevel: "INFO",
}
}
func Load() (Config, error) {
cfg := Default()
if configPath := os.Getenv("MD_HUB_CONFIG"); configPath != "" {
if err := loadFile(configPath, &cfg); err != nil {
return Config{}, err
}
}
overrideString(&cfg.Server.Addr, "MD_HUB_SERVER_ADDR")
overrideString(&cfg.Database.Path, "MD_HUB_DATABASE_PATH")
overrideString(&cfg.Database.PrimaryURL, "MD_HUB_DATABASE_PRIMARY_URL")
overrideString(&cfg.Database.AuthToken, "MD_HUB_DATABASE_AUTH_TOKEN")
overrideString(&cfg.Content.SourceDir, "MD_HUB_CONTENT_SOURCE_DIR")
overrideString(&cfg.Content.StoreDir, "MD_HUB_CONTENT_STORE_DIR")
overrideString(&cfg.Web.DistDir, "MD_HUB_WEB_DIST_DIR")
overrideString(&cfg.LogLevel, "MD_HUB_LOG_LEVEL")
if cfg.Server.Addr == "" {
return Config{}, fmt.Errorf("server.addr must not be empty")
}
return cfg, nil
}
func loadFile(path string, cfg *Config) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open config file %q: %w", path, err)
}
defer file.Close()
if err := json.NewDecoder(file).Decode(cfg); err != nil {
return fmt.Errorf("decode config file %q: %w", path, err)
}
return nil
}
func overrideString(target *string, key string) {
if value := os.Getenv(key); value != "" {
*target = value
}
}

View File

@@ -0,0 +1,79 @@
package database
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
libsql "github.com/tursodatabase/go-libsql"
"github.com/tim/md-hub-secure/apps/server/internal/config"
)
type DB interface {
SQL() *sql.DB
Close() error
}
type dbHandle struct {
sql *sql.DB
}
func Open(ctx context.Context, cfg config.DatabaseConfig) (DB, error) {
if err := os.MkdirAll(filepath.Dir(cfg.Path), 0o755); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
var (
db *sql.DB
err error
)
if cfg.PrimaryURL == "" {
db, err = sql.Open("libsql", "file:"+cfg.Path)
if err != nil {
return nil, fmt.Errorf("open local libsql database: %w", err)
}
} else {
var opts []libsql.Option
if cfg.AuthToken != "" {
opts = append(opts, libsql.WithAuthToken(cfg.AuthToken))
}
connector, err := libsql.NewEmbeddedReplicaConnector(cfg.Path, cfg.PrimaryURL, opts...)
if err != nil {
return nil, fmt.Errorf("create libsql connector: %w", err)
}
db = sql.OpenDB(connector)
}
db.SetConnMaxLifetime(5 * time.Minute)
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
var journalMode string
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode=WAL").Scan(&journalMode); err != nil {
return nil, fmt.Errorf("enable wal mode: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil {
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
return &dbHandle{sql: db}, nil
}
func (d *dbHandle) SQL() *sql.DB {
return d.sql
}
func (d *dbHandle) Close() error {
return d.sql.Close()
}

View File

@@ -0,0 +1,98 @@
package database
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
entries, err := fs.ReadDir(migrationFiles, "migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
var names []string
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".up.sql") {
continue
}
names = append(names, entry.Name())
}
sort.Strings(names)
for _, name := range names {
applied, err := migrationApplied(ctx, db, name)
if err != nil {
return err
}
if applied {
continue
}
body, err := migrationFiles.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
for _, statement := range splitStatements(string(body)) {
if _, err := tx.ExecContext(ctx, statement); err != nil {
_ = tx.Rollback()
return fmt.Errorf("execute migration %s: %w", name, err)
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES (?, datetime('now'))`, name); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}
func migrationApplied(ctx context.Context, db *sql.DB, version string) (bool, error) {
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version).Scan(&count); err != nil {
return false, fmt.Errorf("lookup migration %s: %w", version, err)
}
return count > 0, nil
}
func splitStatements(body string) []string {
raw := strings.Split(body, ";")
statements := make([]string, 0, len(raw))
for _, statement := range raw {
statement = strings.TrimSpace(statement)
if statement == "" {
continue
}
statements = append(statements, statement)
}
return statements
}

View File

@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS attachments;
DROP TABLE IF EXISTS document_versions;
DROP TABLE IF EXISTS documents;
DROP TABLE IF EXISTS users;

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT,
created_at TEXT NOT NULL,
last_seen_at TEXT
);

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS documents;

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
current_hash TEXT NOT NULL,
title TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS document_versions;

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS document_versions (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
hash TEXT NOT NULL,
previous_hash TEXT,
created_at TEXT NOT NULL,
change_summary TEXT,
UNIQUE(document_id, hash),
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS attachments;

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS attachments (
hash TEXT PRIMARY KEY,
original_name TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TEXT NOT NULL
);

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_documents_path;
DROP INDEX IF EXISTS idx_document_versions_document_id;

View File

@@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(path);
CREATE INDEX IF NOT EXISTS idx_document_versions_document_id ON document_versions(document_id);

View File

@@ -0,0 +1,205 @@
package docs
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
type Repository struct {
db *sql.DB
}
type DocumentRecord struct {
ID string
Path string
CurrentHash string
Title string
Tags []string
CreatedAt time.Time
UpdatedAt time.Time
}
type AttachmentRecord struct {
Hash string
OriginalName string
ContentType string
SizeBytes int64
CreatedAt time.Time
}
type PersistDocumentInput struct {
ID string
Path string
Title string
Hash string
PreviousHash string
Tags []string
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentInput) error {
now := time.Now().UTC()
tagsJSON, err := json.Marshal(input.Tags)
if err != nil {
return fmt.Errorf("marshal tags: %w", err)
}
existing, err := r.GetDocumentByPath(ctx, input.Path)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
documentID := input.ID
createdAt := now
if existing != nil {
documentID = existing.ID
createdAt = existing.CreatedAt
}
if _, err := r.db.ExecContext(ctx, `
INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
current_hash = excluded.current_hash,
title = excluded.title,
tags = excluded.tags,
updated_at = excluded.updated_at
`, documentID, input.Path, input.Hash, input.Title, string(tagsJSON), createdAt.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
return fmt.Errorf("upsert document %s: %w", input.Path, err)
}
if existing != nil && existing.CurrentHash == input.Hash {
return nil
}
versionID := documentID + ":" + input.Hash[:12]
if _, err := r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO document_versions (id, document_id, hash, previous_hash, created_at, change_summary)
VALUES (?, ?, ?, ?, ?, ?)
`, versionID, documentID, input.Hash, input.PreviousHash, now.Format(time.RFC3339), "filesystem sync"); err != nil {
return fmt.Errorf("insert document version %s: %w", input.Path, err)
}
return nil
}
func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
var (
record DocumentRecord
tagsJSON string
created string
updated string
)
err := r.db.QueryRowContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at
FROM documents
WHERE path = ?
`, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated)
if err != nil {
return nil, err
}
if err := parseDocumentTimes(&record, created, updated); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
return nil, fmt.Errorf("unmarshal document tags: %w", err)
}
return &record, nil
}
func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at
FROM documents
ORDER BY path ASC
`)
if err != nil {
return nil, fmt.Errorf("list documents: %w", err)
}
defer rows.Close()
var records []DocumentRecord
for rows.Next() {
var (
record DocumentRecord
tagsJSON string
created string
updated string
)
if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated); err != nil {
return nil, fmt.Errorf("scan document: %w", err)
}
if err := parseDocumentTimes(&record, created, updated); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
return nil, fmt.Errorf("unmarshal document tags: %w", err)
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(hash) DO UPDATE SET
original_name = excluded.original_name,
content_type = excluded.content_type,
size_bytes = excluded.size_bytes
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339)); err != nil {
return fmt.Errorf("save attachment: %w", err)
}
return nil
}
func (r *Repository) GetAttachment(ctx context.Context, hash string) (*AttachmentRecord, error) {
var (
record AttachmentRecord
created string
)
err := r.db.QueryRowContext(ctx, `
SELECT hash, original_name, content_type, size_bytes, created_at
FROM attachments
WHERE hash = ?
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created)
if err != nil {
return nil, err
}
parsed, err := time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse attachment created_at: %w", err)
}
record.CreatedAt = parsed
return &record, nil
}
func (r *Repository) Ping(ctx context.Context) error {
return r.db.PingContext(ctx)
}
func parseDocumentTimes(record *DocumentRecord, created string, updated string) error {
createdAt, err := time.Parse(time.RFC3339, created)
if err != nil {
return fmt.Errorf("parse document created_at: %w", err)
}
updatedAt, err := time.Parse(time.RFC3339, updated)
if err != nil {
return fmt.Errorf("parse document updated_at: %w", err)
}
record.CreatedAt = createdAt
record.UpdatedAt = updatedAt
return nil
}

View File

@@ -0,0 +1,154 @@
package docs
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/tim/md-hub-secure/apps/server/internal/markdown"
"github.com/tim/md-hub-secure/apps/server/internal/store"
)
type Service struct {
sourceDir string
store *store.ContentStore
renderer *markdown.Renderer
repo *Repository
logger *slog.Logger
}
type Page struct {
Path string
Title string
Tags []string
HTML string
Hash string
}
func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service {
return &Service{
sourceDir: sourceDir,
store: store,
renderer: renderer,
repo: repo,
logger: logger,
}
}
func (s *Service) SyncSourceDir(ctx context.Context) error {
return filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || filepath.Ext(path) != ".md" {
return nil
}
return s.syncFile(ctx, path)
})
}
func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
if err := s.SyncSourceDir(ctx); err != nil {
return nil, err
}
return s.repo.ListDocuments(ctx)
}
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
if err := s.SyncSourceDir(ctx); err != nil {
return nil, err
}
normalized := normalizeRequestPath(requestPath)
record, err := s.repo.GetDocumentByPath(ctx, normalized)
if err != nil {
return nil, err
}
content, err := s.store.Read(record.CurrentHash)
if err != nil {
return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err)
}
rendered, err := s.renderer.Render(content)
if err != nil {
return nil, fmt.Errorf("render page %s: %w", normalized, err)
}
return &Page{
Path: record.Path,
Title: rendered.Title,
Tags: record.Tags,
HTML: string(rendered.HTML),
Hash: record.CurrentHash,
}, nil
}
func normalizeRequestPath(path string) string {
path = strings.Trim(path, "/")
if path == "" {
return "getting-started.md"
}
if !strings.HasSuffix(path, ".md") {
path += ".md"
}
return path
}
func (s *Service) syncFile(ctx context.Context, path string) error {
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read content file %s: %w", path, err)
}
rendered, err := s.renderer.Render(content)
if err != nil {
return fmt.Errorf("render content file %s: %w", path, err)
}
record, err := s.store.PutBytes(content)
if err != nil {
return fmt.Errorf("store content file %s: %w", path, err)
}
relative, err := filepath.Rel(s.sourceDir, path)
if err != nil {
return fmt.Errorf("compute relative path %s: %w", path, err)
}
relative = filepath.ToSlash(relative)
existing, err := s.repo.GetDocumentByPath(ctx, relative)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
previousHash := ""
documentID := "doc:" + strings.TrimSuffix(relative, ".md")
if existing != nil {
previousHash = existing.CurrentHash
documentID = existing.ID
if existing.CurrentHash == record.Hash {
return nil
}
}
if err := s.repo.PersistDocument(ctx, PersistDocumentInput{
ID: documentID,
Path: relative,
Title: rendered.Title,
Hash: record.Hash,
PreviousHash: previousHash,
Tags: rendered.Tags,
}); err != nil {
return err
}
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
return nil
}

View File

@@ -0,0 +1,7 @@
package httpserver
import "io/fs"
func fsSub(path string) (fs.FS, error) {
return fs.Sub(assets, path)
}

View File

@@ -0,0 +1,242 @@
package httpserver
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
)
type layoutData struct {
Title string
WebEnabled bool
BodyClass string
BodyTemplate string
Data any
}
type indexData struct {
Documents []docs.DocumentRecord
}
type documentData struct {
Title string
Path string
Tags []string
Hash string
HTML template.HTML
}
type errorData struct {
Status int
Message string
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
Title: "MD Hub Secure",
WebEnabled: s.webEnabled,
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
Documents: items,
},
})
}
func (s *Server) handleDocsIndexRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
page, err := s.documents.LoadPage(r.Context(), pagePath)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
},
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbStatus := "ok"
if err := s.repository.Ping(ctx); err != nil {
dbStatus = err.Error()
}
contentStatus := "ok"
if _, err := os.Stat(s.config.Content.StoreDir); err != nil {
contentStatus = err.Error()
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"database": dbStatus,
"contentStore": contentStatus,
})
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
s.renderError(w, r, http.StatusBadRequest, "invalid multipart upload")
return
}
file, header, err := r.FormFile("file")
if err != nil {
s.renderError(w, r, http.StatusBadRequest, "file field is required")
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, 32<<20))
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "read upload")
return
}
contentType := http.DetectContentType(content)
if !isAllowedContentType(contentType) {
s.renderError(w, r, http.StatusBadRequest, "unsupported content type")
return
}
record, err := s.contentStore.PutBytes(content)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "store upload")
return
}
if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
Hash: record.Hash,
OriginalName: header.Filename,
ContentType: contentType,
SizeBytes: record.Size,
CreatedAt: time.Now().UTC(),
}); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
return
}
writeJSONWithStatus(w, http.StatusCreated, map[string]string{
"hash": record.Hash,
"contentType": contentType,
"attachmentUrl": "/attachments/" + record.Hash,
})
}
func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
attachment, err := s.repository.GetAttachment(r.Context(), hash)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusNotFound, "attachment not found")
return
}
s.renderError(w, r, http.StatusInternalServerError, "lookup attachment")
return
}
content, err := s.contentStore.Read(hash)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "read attachment")
return
}
w.Header().Set("Content-Type", attachment.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", sanitizeFilename(attachment.OriginalName)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderTemplate(w, status, "error.gohtml", layoutData{
Title: http.StatusText(status),
WebEnabled: s.webEnabled,
BodyClass: "page-error",
BodyTemplate: "error_content",
Data: errorData{
Status: status,
Message: message,
},
})
}
func isAllowedContentType(contentType string) bool {
allowed := []string{
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/pdf",
"text/plain; charset=utf-8",
}
for _, candidate := range allowed {
if strings.EqualFold(candidate, contentType) {
return true
}
}
return false
}
func sanitizeFilename(name string) string {
name = filepath.Base(name)
return strings.ReplaceAll(name, "\"", "")
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
writeJSONWithStatus(w, status, payload)
}
func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}

View File

@@ -0,0 +1,42 @@
package httpserver
import (
"net/http"
"time"
)
func (s *Server) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next.ServeHTTP(ww, r)
s.logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.status,
"duration", time.Since(start).String(),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}

View File

@@ -0,0 +1,93 @@
package httpserver
import (
"embed"
"html/template"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/tim/md-hub-secure/apps/server/internal/config"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
"github.com/tim/md-hub-secure/apps/server/internal/store"
)
//go:embed templates/*.gohtml static/*
var assets embed.FS
type Dependencies struct {
Config config.Config
Logger *slog.Logger
Documents *docs.Service
Repository *docs.Repository
ContentStore *store.ContentStore
}
type Server struct {
config config.Config
logger *slog.Logger
documents *docs.Service
repository *docs.Repository
contentStore *store.ContentStore
templates *template.Template
webEnabled bool
}
func New(deps Dependencies) (http.Handler, error) {
templates, err := template.New("").Funcs(template.FuncMap{
"trimMd": func(path string) string {
return strings.TrimSuffix(path, ".md")
},
}).ParseFS(assets, "templates/*.gohtml")
if err != nil {
return nil, err
}
server := &Server{
config: deps.Config,
logger: deps.Logger,
documents: deps.Documents,
repository: deps.Repository,
contentStore: deps.ContentStore,
templates: templates,
}
if _, err := os.Stat(deps.Config.Web.DistDir); err == nil {
server.webEnabled = true
}
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(middleware.Recoverer)
router.Use(middleware.Timeout(30 * time.Second))
router.Use(server.requestLogger)
router.Use(server.securityHeaders)
router.Get("/", server.handleIndex)
router.Get("/health", server.handleHealth)
router.Get("/docs", server.handleDocsIndexRedirect)
router.Get("/docs/*", server.handleDocument)
router.Post("/api/uploads", server.handleUpload)
router.Get("/attachments/{hash}", server.handleAttachment)
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
if server.webEnabled {
router.Handle("/app/*", http.StripPrefix("/app/", http.FileServer(http.Dir(deps.Config.Web.DistDir))))
}
return router, nil
}
func mustSub(path string) http.FileSystem {
sub, err := fsSub(path)
if err != nil {
panic(err)
}
return http.FS(sub)
}

View File

@@ -0,0 +1,194 @@
:root {
color-scheme: light;
--bg: #fbf7ef;
--panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff;
--text: #18202a;
--muted: #5b6675;
--accent: #0f5bd8;
--accent-soft: rgba(15, 91, 216, 0.1);
--border: rgba(24, 32, 42, 0.12);
--shadow: 0 16px 48px rgba(24, 32, 42, 0.08);
--radius-lg: 24px;
--radius-md: 16px;
--radius-sm: 10px;
font-family: "Charter", "Iowan Old Style", "Palatino Linotype", serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(15, 91, 216, 0.16), transparent 34%),
radial-gradient(circle at bottom right, rgba(14, 163, 125, 0.13), transparent 28%),
linear-gradient(180deg, #fdfaf5 0%, #f5efe6 100%);
}
a {
color: var(--accent);
}
code {
font-family: ui-monospace, SFMono-Regular, monospace;
}
.site-header {
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid rgba(255, 255, 255, 0.42);
background: rgba(251, 247, 239, 0.92);
backdrop-filter: blur(12px);
}
.site-header__inner,
.site-main {
width: min(1080px, calc(100vw - 2rem));
margin: 0 auto;
}
.site-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72px;
}
.site-brand {
color: var(--text);
font-size: 1.15rem;
font-weight: 700;
text-decoration: none;
}
.site-nav {
display: flex;
gap: 1rem;
}
.site-nav a {
text-decoration: none;
}
.site-main {
padding: 2rem 0 4rem;
}
.hero-panel,
.doc-list,
.document-shell,
.error-panel {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-panel,
.error-panel {
padding: 2rem;
}
.doc-list,
.document-shell {
margin-top: 1rem;
padding: 1.5rem 2rem;
}
.eyebrow {
margin: 0 0 0.8rem;
color: var(--accent);
font: 700 0.78rem/1.2 ui-monospace, SFMono-Regular, monospace;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.lede {
max-width: 44rem;
color: var(--muted);
line-height: 1.7;
}
.doc-list ul,
.tag-list {
margin: 0;
padding: 0;
list-style: none;
}
.doc-list li {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 0;
border-top: 1px solid var(--border);
}
.doc-list li:first-child {
border-top: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-list a {
display: inline-flex;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: var(--accent-soft);
text-decoration: none;
}
.hash {
color: var(--muted);
overflow-wrap: anywhere;
}
.markdown-body {
line-height: 1.75;
}
.markdown-body pre {
overflow-x: auto;
padding: 1rem;
border-radius: var(--radius-md);
background: #18202a;
color: #f7f9fc;
}
.markdown-body blockquote {
margin-left: 0;
padding: 0.9rem 1rem;
border-left: 4px solid var(--accent);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: rgba(15, 91, 216, 0.05);
}
.markdown-body img {
max-width: 100%;
}
@media (max-width: 720px) {
.site-header__inner {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 0;
}
.doc-list,
.document-shell {
padding: 1.25rem;
}
}

View File

@@ -0,0 +1,32 @@
{{ define "base" }}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/site.css" />
</head>
<body class="{{ .BodyClass }}">
<header class="site-header">
<div class="site-header__inner">
<a class="site-brand" href="/">MD Hub Secure</a>
<nav class="site-nav">
<a href="/">Docs</a>
{{ if .WebEnabled }}<a href="/app/">App</a>{{ end }}
<a href="/health">Health</a>
</nav>
</div>
</header>
<main class="site-main">
{{ if eq .BodyTemplate "index_content" }}
{{ template "index_content" .Data }}
{{ else if eq .BodyTemplate "document_content" }}
{{ template "document_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }}
{{ template "error_content" .Data }}
{{ end }}
</main>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,22 @@
{{ define "document.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "document_content" }}
<article class="document-shell">
<div class="document-meta">
<p class="eyebrow">{{ .Path }}</p>
<h1>{{ .Title }}</h1>
{{ if .Tags }}
<ul class="tag-list">
{{ range .Tags }}
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
{{ end }}
</ul>
{{ end }}
<p class="hash">SHA-256: <code>{{ .Hash }}</code></p>
</div>
<div class="markdown-body">
{{ .HTML }}
</div>
</article>
{{ end }}

View File

@@ -0,0 +1,9 @@
{{ define "error.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "error_content" }}
<section class="error-panel">
<p class="eyebrow">Error</p>
<h1>{{ .Status }}</h1>
<p>{{ .Message }}</p>
</section>
{{ end }}

View File

@@ -0,0 +1,23 @@
{{ define "index.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "index_content" }}
<section class="hero-panel">
<p class="eyebrow">Foundation</p>
<h1>Server-rendered Markdown, secure attachments, and a clean base for sync.</h1>
<p class="lede">This milestone focuses on a fast read path. Documents are synchronized from the content directory into content-addressed storage and rendered to HTML on demand.</p>
</section>
<section class="doc-list">
<h2>Documents</h2>
<ul>
{{ range .Documents }}
<li>
<a href="/docs/{{ trimMd .Path }}">{{ .Title }}</a>
<code>{{ .Path }}</code>
</li>
{{ else }}
<li>No documents found.</li>
{{ end }}
</ul>
</section>
{{ end }}

View File

@@ -0,0 +1,24 @@
package logging
import (
"log/slog"
"os"
"strings"
)
func New(level string) *slog.Logger {
var slogLevel slog.Level
switch strings.ToUpper(level) {
case "DEBUG":
slogLevel = slog.LevelDebug
case "WARN":
slogLevel = slog.LevelWarn
case "ERROR":
slogLevel = slog.LevelError
default:
slogLevel = slog.LevelInfo
}
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slogLevel})
return slog.New(handler)
}

View File

@@ -0,0 +1,168 @@
package markdown
import (
"bytes"
"fmt"
"html/template"
"regexp"
"slices"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
var (
tagPattern = regexp.MustCompile(`(^|[\s(])#([a-zA-Z0-9_-]+)\b`)
)
type Result struct {
HTML template.HTML
Title string
Tags []string
}
type Renderer struct {
markdown goldmark.Markdown
}
func NewRenderer() *Renderer {
return &Renderer{
markdown: goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Footnote,
extension.DefinitionList,
extension.Table,
extension.Strikethrough,
extension.TaskList,
extension.Typographer,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
),
}
}
func (r *Renderer) Render(content []byte) (Result, error) {
title := extractTitle(string(content))
tags := extractTags(string(content))
prepared := preprocess(string(content))
var output bytes.Buffer
if err := r.markdown.Convert([]byte(prepared), &output); err != nil {
return Result{}, fmt.Errorf("render markdown: %w", err)
}
return Result{
HTML: template.HTML(output.String()),
Title: title,
Tags: tags,
}, nil
}
func preprocess(input string) string {
lines := strings.Split(input, "\n")
for index, line := range lines {
line = rewriteWikiLinks(line)
line = normalizeAdmonition(line)
lines[index] = line
}
return strings.Join(lines, "\n")
}
func rewriteWikiLinks(line string) string {
var builder strings.Builder
remaining := line
for {
start := strings.Index(remaining, "[[")
if start == -1 {
builder.WriteString(remaining)
return builder.String()
}
end := strings.Index(remaining[start+2:], "]]")
if end == -1 {
builder.WriteString(remaining)
return builder.String()
}
builder.WriteString(remaining[:start])
label := strings.TrimSpace(remaining[start+2 : start+2+end])
if label == "" {
builder.WriteString(remaining[start : start+2+end+2])
} else {
builder.WriteString(fmt.Sprintf("[%s](/docs/%s)", label, slugify(label)))
}
remaining = remaining[start+2+end+2:]
}
}
func normalizeAdmonition(line string) string {
switch {
case strings.HasPrefix(line, "> [!NOTE]"):
return strings.Replace(line, "> [!NOTE]", "> **Note:**", 1)
case strings.HasPrefix(line, "> [!TIP]"):
return strings.Replace(line, "> [!TIP]", "> **Tip:**", 1)
case strings.HasPrefix(line, "> [!WARNING]"):
return strings.Replace(line, "> [!WARNING]", "> **Warning:**", 1)
default:
return line
}
}
func extractTitle(content string) string {
for _, line := range strings.Split(content, "\n") {
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
}
return "Untitled"
}
func extractTags(content string) []string {
matches := tagPattern.FindAllStringSubmatch(content, -1)
tags := make([]string, 0, len(matches))
seen := make(map[string]struct{}, len(matches))
for _, match := range matches {
if len(match) < 3 {
continue
}
tag := match[2]
if _, exists := seen[tag]; exists {
continue
}
seen[tag] = struct{}{}
tags = append(tags, tag)
}
slices.Sort(tags)
return tags
}
func slugify(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var builder strings.Builder
lastDash := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
builder.WriteRune(r)
lastDash = false
default:
if !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}

View File

@@ -0,0 +1,23 @@
package markdown
import (
"strings"
"testing"
)
func TestRenderRewritesWikiLinksAndTags(t *testing.T) {
renderer := NewRenderer()
result, err := renderer.Render([]byte("# Title\n\nLink to [[Project Plan]] and #alpha #beta.\n"))
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if !strings.Contains(string(result.HTML), `/docs/project-plan`) {
t.Fatalf("expected wiki-link rewrite, got %s", result.HTML)
}
if len(result.Tags) != 2 || result.Tags[0] != "alpha" || result.Tags[1] != "beta" {
t.Fatalf("unexpected tags: %#v", result.Tags)
}
}

View File

@@ -0,0 +1,70 @@
package store
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
)
type Record struct {
Hash string
Path string
Size int64
}
type ContentStore struct {
root string
}
func New(root string) (*ContentStore, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create content store root: %w", err)
}
return &ContentStore{root: root}, nil
}
func (s *ContentStore) PutBytes(content []byte) (Record, error) {
return s.put(bytes.NewReader(content))
}
func (s *ContentStore) PutReader(reader io.Reader) (Record, error) {
return s.put(reader)
}
func (s *ContentStore) Read(hash string) ([]byte, error) {
return os.ReadFile(s.PathForHash(hash))
}
func (s *ContentStore) PathForHash(hash string) string {
return filepath.Join(s.root, hash[0:2], hash[2:4], hash)
}
func (s *ContentStore) put(reader io.Reader) (Record, error) {
content, err := io.ReadAll(reader)
if err != nil {
return Record{}, fmt.Errorf("read content: %w", err)
}
sum := sha256.Sum256(content)
hash := hex.EncodeToString(sum[:])
target := s.PathForHash(hash)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return Record{}, fmt.Errorf("create content directory: %w", err)
}
if _, err := os.Stat(target); err == nil {
return Record{Hash: hash, Path: target, Size: int64(len(content))}, nil
}
if err := os.WriteFile(target, content, 0o644); err != nil {
return Record{}, fmt.Errorf("write content file: %w", err)
}
return Record{Hash: hash, Path: target, Size: int64(len(content))}, nil
}

View File

@@ -0,0 +1,34 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestContentStoreDeduplicatesByHash(t *testing.T) {
root := t.TempDir()
store, err := New(root)
if err != nil {
t.Fatalf("New() error = %v", err)
}
first, err := store.PutBytes([]byte("hello"))
if err != nil {
t.Fatalf("PutBytes() first error = %v", err)
}
second, err := store.PutBytes([]byte("hello"))
if err != nil {
t.Fatalf("PutBytes() second error = %v", err)
}
if first.Hash != second.Hash {
t.Fatalf("hash mismatch: %s != %s", first.Hash, second.Hash)
}
if _, err := os.Stat(filepath.Join(root, first.Hash[0:2], first.Hash[2:4], first.Hash)); err != nil {
t.Fatalf("expected content file to exist: %v", err)
}
}

2
apps/web/.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
dist/

12
apps/web/index.html Normal file
View File

@@ -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>MD Hub Secure App</title>
<script type="module" src="/src/main.tsx"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

2108
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
apps/web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "md-hub-secure-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"format": "prettier --check .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"preact": "^10.27.2",
"preact-iso": "^2.9.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"vite": "^7.1.12"
}
}

31
apps/web/src/app.tsx Normal file
View File

@@ -0,0 +1,31 @@
export function App() {
return (
<main class="shell">
<section class="hero">
<p class="eyebrow">MD Hub Secure</p>
<h1>Preact foundation for future hydrated features</h1>
<p class="lede">
The Go server is the source of truth for rendering document pages.
This app is the scaffold for search, sync, comments, and design
tooling that will hydrate over time.
</p>
</section>
<section class="grid">
<article class="card">
<h2>Current scope</h2>
<p>
Routing, bundling, TypeScript, and styles are in place for later
feature islands.
</p>
</article>
<article class="card">
<h2>Next milestone</h2>
<p>
Sync protocol UI, offline cache status, and browser-side search.
</p>
</article>
</section>
</main>
);
}

11
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { render } from "preact";
import { LocationProvider, Route, Router } from "preact-iso";
import { App } from "./app";
import "./styles.css";
render(
<LocationProvider>
<Router>{[<Route path="/*" component={App} />]}</Router>
</LocationProvider>,
document.getElementById("app")!,
);

91
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,91 @@
:root {
color-scheme: light;
--bg: #f4f1ea;
--surface: rgba(255, 255, 255, 0.78);
--surface-strong: #ffffff;
--text: #202227;
--muted: #5f6471;
--accent: #1947e5;
--accent-2: #0f8a6c;
--border: rgba(32, 34, 39, 0.12);
--shadow: 0 20px 60px rgba(25, 71, 229, 0.12);
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(
circle at top left,
rgba(25, 71, 229, 0.16),
transparent 36%
),
radial-gradient(
circle at bottom right,
rgba(15, 138, 108, 0.18),
transparent 30%
),
linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%);
}
.shell {
max-width: 960px;
margin: 0 auto;
padding: 4rem 1.5rem 5rem;
}
.hero {
padding: 2rem;
border: 1px solid var(--border);
border-radius: 28px;
background: var(--surface);
backdrop-filter: blur(10px);
box-shadow: var(--shadow);
}
.eyebrow {
margin: 0 0 0.75rem;
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.82rem;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
font-size: clamp(2.25rem, 5vw, 4rem);
line-height: 0.95;
}
.lede {
max-width: 42rem;
margin: 1.5rem 0 0;
font-size: 1.1rem;
line-height: 1.7;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.card {
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface-strong);
}
.card h2 {
margin-top: 0;
}

21
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["src"]
}

10
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
export default defineConfig({
plugins: [preact()],
build: {
outDir: "dist",
sourcemap: true,
},
});