feat: bootstrap foundation application
This commit is contained in:
45
apps/server/cmd/md-hub-secure/main.go
Normal file
45
apps/server/cmd/md-hub-secure/main.go
Normal 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
15
apps/server/go.mod
Normal 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
20
apps/server/go.sum
Normal 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=
|
||||
98
apps/server/internal/app/app.go
Normal file
98
apps/server/internal/app/app.go
Normal 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()
|
||||
}
|
||||
99
apps/server/internal/config/config.go
Normal file
99
apps/server/internal/config/config.go
Normal 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
|
||||
}
|
||||
}
|
||||
79
apps/server/internal/database/database.go
Normal file
79
apps/server/internal/database/database.go
Normal 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()
|
||||
}
|
||||
98
apps/server/internal/database/migrate.go
Normal file
98
apps/server/internal/database/migrate.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS documents;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS document_versions;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS attachments;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_documents_path;
|
||||
DROP INDEX IF EXISTS idx_document_versions_document_id;
|
||||
|
||||
@@ -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);
|
||||
|
||||
205
apps/server/internal/docs/repository.go
Normal file
205
apps/server/internal/docs/repository.go
Normal 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
|
||||
}
|
||||
154
apps/server/internal/docs/service.go
Normal file
154
apps/server/internal/docs/service.go
Normal 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
|
||||
}
|
||||
7
apps/server/internal/httpserver/fs.go
Normal file
7
apps/server/internal/httpserver/fs.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package httpserver
|
||||
|
||||
import "io/fs"
|
||||
|
||||
func fsSub(path string) (fs.FS, error) {
|
||||
return fs.Sub(assets, path)
|
||||
}
|
||||
242
apps/server/internal/httpserver/handlers.go
Normal file
242
apps/server/internal/httpserver/handlers.go
Normal 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)
|
||||
}
|
||||
42
apps/server/internal/httpserver/middleware.go
Normal file
42
apps/server/internal/httpserver/middleware.go
Normal 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)
|
||||
}
|
||||
93
apps/server/internal/httpserver/server.go
Normal file
93
apps/server/internal/httpserver/server.go
Normal 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)
|
||||
}
|
||||
194
apps/server/internal/httpserver/static/site.css
Normal file
194
apps/server/internal/httpserver/static/site.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
32
apps/server/internal/httpserver/templates/base.gohtml
Normal file
32
apps/server/internal/httpserver/templates/base.gohtml
Normal 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 }}
|
||||
22
apps/server/internal/httpserver/templates/document.gohtml
Normal file
22
apps/server/internal/httpserver/templates/document.gohtml
Normal 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 }}
|
||||
9
apps/server/internal/httpserver/templates/error.gohtml
Normal file
9
apps/server/internal/httpserver/templates/error.gohtml
Normal 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 }}
|
||||
23
apps/server/internal/httpserver/templates/index.gohtml
Normal file
23
apps/server/internal/httpserver/templates/index.gohtml
Normal 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 }}
|
||||
24
apps/server/internal/logging/logging.go
Normal file
24
apps/server/internal/logging/logging.go
Normal 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)
|
||||
}
|
||||
168
apps/server/internal/markdown/renderer.go
Normal file
168
apps/server/internal/markdown/renderer.go
Normal 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(), "-")
|
||||
}
|
||||
23
apps/server/internal/markdown/renderer_test.go
Normal file
23
apps/server/internal/markdown/renderer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
70
apps/server/internal/store/content_store.go
Normal file
70
apps/server/internal/store/content_store.go
Normal 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
|
||||
}
|
||||
34
apps/server/internal/store/content_store_test.go
Normal file
34
apps/server/internal/store/content_store_test.go
Normal 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
2
apps/web/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal 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
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
22
apps/web/package.json
Normal 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
31
apps/web/src/app.tsx
Normal 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
11
apps/web/src/main.tsx
Normal 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
91
apps/web/src/styles.css
Normal 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
21
apps/web/tsconfig.json
Normal 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
10
apps/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user