feat: bootstrap foundation application
This commit is contained in:
48
.github/workflows/ci.yml
vendored
Normal file
48
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- "codex/**"
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.24.2"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: apps/web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install build tooling
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y build-essential
|
||||||
|
|
||||||
|
- name: Install web dependencies
|
||||||
|
run: cd apps/web && npm install
|
||||||
|
|
||||||
|
- name: Build web
|
||||||
|
run: cd apps/web && npm run build
|
||||||
|
|
||||||
|
- name: Run Go tests
|
||||||
|
run: cd apps/server && CGO_ENABLED=1 go test ./...
|
||||||
|
|
||||||
|
- name: Build server
|
||||||
|
run: cd apps/server && CGO_ENABLED=1 go build -trimpath ./cmd/md-hub-secure
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
cd apps/server && govulncheck ./...
|
||||||
|
|
||||||
|
- name: Build container
|
||||||
|
run: docker build .
|
||||||
|
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
data/
|
||||||
|
tmp/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
apps/web/node_modules/
|
||||||
|
apps/web/dist/
|
||||||
|
apps/server/bin/
|
||||||
|
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM node:24-bookworm-slim AS web-build
|
||||||
|
WORKDIR /workspace/apps/web
|
||||||
|
COPY apps/web/package.json apps/web/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY apps/web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.24-bookworm AS server-build
|
||||||
|
WORKDIR /workspace
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY go.work ./
|
||||||
|
COPY apps/server/go.mod apps/server/go.sum* ./apps/server/
|
||||||
|
WORKDIR /workspace/apps/server
|
||||||
|
RUN go mod download
|
||||||
|
COPY apps/server/ ./
|
||||||
|
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/md-hub-secure ./cmd/md-hub-secure
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN useradd --create-home --shell /usr/sbin/nologin appuser
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY --from=server-build /workspace/md-hub-secure /usr/local/bin/md-hub-secure
|
||||||
|
COPY --from=web-build /workspace/apps/web/dist /workspace/web-dist
|
||||||
|
COPY content /workspace/content
|
||||||
|
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
|
||||||
|
USER appuser
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV MD_HUB_SERVER_ADDR=:8080
|
||||||
|
ENV MD_HUB_DATABASE_PATH=/workspace/data/db.sqlite
|
||||||
|
ENV MD_HUB_CONTENT_SOURCE_DIR=/workspace/content
|
||||||
|
ENV MD_HUB_CONTENT_STORE_DIR=/workspace/data/files
|
||||||
|
ENV MD_HUB_WEB_DIST_DIR=/workspace/web-dist
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1
|
||||||
|
ENTRYPOINT ["/usr/local/bin/md-hub-secure"]
|
||||||
|
|
||||||
34
Makefile
Normal file
34
Makefile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
GO := go
|
||||||
|
NPM := npm
|
||||||
|
SERVER_DIR := apps/server
|
||||||
|
WEB_DIR := apps/web
|
||||||
|
SERVER_BIN := $(SERVER_DIR)/bin/md-hub-secure
|
||||||
|
|
||||||
|
.PHONY: web-install web-build server-run server-build server-test docker-build ci fmt
|
||||||
|
|
||||||
|
web-install:
|
||||||
|
cd $(WEB_DIR) && $(NPM) install
|
||||||
|
|
||||||
|
web-build:
|
||||||
|
cd $(WEB_DIR) && $(NPM) run build
|
||||||
|
|
||||||
|
server-run:
|
||||||
|
cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) run ./cmd/md-hub-secure
|
||||||
|
|
||||||
|
server-build:
|
||||||
|
mkdir -p $(SERVER_DIR)/bin
|
||||||
|
cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) build -trimpath -o bin/md-hub-secure ./cmd/md-hub-secure
|
||||||
|
|
||||||
|
server-test:
|
||||||
|
cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) test ./...
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t md-hub-secure:dev .
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cd $(SERVER_DIR) && $(GO) fmt ./...
|
||||||
|
cd $(WEB_DIR) && $(NPM) run format
|
||||||
|
|
||||||
|
ci: web-build server-test server-build
|
||||||
|
|
||||||
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# MD Hub Secure
|
||||||
|
|
||||||
|
MD Hub Secure is a Go-first documentation platform for serving Markdown as secure, fast HTML with room for later sync, collaboration, and offline features.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
This repository now contains the Milestone 1 foundation scaffold:
|
||||||
|
|
||||||
|
- Go application server with chi routing, slog logging, config loading, and graceful shutdown
|
||||||
|
- SQLite/libsql-oriented data layer with startup migrations
|
||||||
|
- Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates
|
||||||
|
- Content-addressed attachment storage with secure serving
|
||||||
|
- Preact + Vite frontend scaffold for later hydration-focused UI work
|
||||||
|
- Docker, Compose, CI, OpenAPI, and local development targets
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.24+
|
||||||
|
- Node.js 24+
|
||||||
|
- npm 11+
|
||||||
|
- Docker (optional)
|
||||||
|
- CGO-capable toolchain for `go-libsql`
|
||||||
|
|
||||||
|
### Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web-install
|
||||||
|
make web-build
|
||||||
|
make server-run
|
||||||
|
```
|
||||||
|
|
||||||
|
The server listens on `http://localhost:8080` by default.
|
||||||
|
|
||||||
|
### Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make server-test
|
||||||
|
make server-build
|
||||||
|
make docker-build
|
||||||
|
make ci
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration can come from:
|
||||||
|
|
||||||
|
1. `config.json` via `MD_HUB_CONFIG`
|
||||||
|
2. environment variables
|
||||||
|
|
||||||
|
Supported variables:
|
||||||
|
|
||||||
|
- `MD_HUB_SERVER_ADDR`
|
||||||
|
- `MD_HUB_DATABASE_PATH`
|
||||||
|
- `MD_HUB_DATABASE_PRIMARY_URL`
|
||||||
|
- `MD_HUB_DATABASE_AUTH_TOKEN`
|
||||||
|
- `MD_HUB_CONTENT_SOURCE_DIR`
|
||||||
|
- `MD_HUB_CONTENT_STORE_DIR`
|
||||||
|
- `MD_HUB_WEB_DIST_DIR`
|
||||||
|
- `MD_HUB_LOG_LEVEL`
|
||||||
|
|
||||||
|
## Noted Gaps
|
||||||
|
|
||||||
|
- The docs describe both a single-binary system and a separate Vite/Preact app. This implementation keeps the Go server as the primary runtime and treats the web app as compiled static assets.
|
||||||
|
- The docs call for `golang-migrate`, but the current foundation uses an internal migration runner while `go-libsql` integration details are validated.
|
||||||
|
- The sync docs describe JSON messages while the Milestone 1 layout mentions a protobuf package; both are preserved in `packages/protocol/` pending a later protocol lock.
|
||||||
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
11
content/architecture-overview.md
Normal file
11
content/architecture-overview.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
MD Hub Secure uses a Go application server as the primary runtime.
|
||||||
|
|
||||||
|
- Markdown documents are read from the source directory.
|
||||||
|
- The current version is written into content-addressed storage.
|
||||||
|
- Metadata lives in the local libsql-compatible database.
|
||||||
|
- HTML is rendered server-side first.
|
||||||
|
|
||||||
|
See [[Getting Started]] for a feature-oriented overview.
|
||||||
|
|
||||||
33
content/getting-started.md
Normal file
33
content/getting-started.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
Welcome to **MD Hub Secure**.
|
||||||
|
|
||||||
|
See [[Architecture Overview]] for the system shape and [[Project Plan]] for the milestone roadmap.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Wiki links
|
||||||
|
- GitHub-flavored Markdown
|
||||||
|
- Tags like #foundation and #security
|
||||||
|
- Admonitions
|
||||||
|
- Mermaid diagrams
|
||||||
|
- Math delimiters for later KaTeX hydration
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This is a representative admonition block.
|
||||||
|
> It should render with a strong callout style.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Markdown] --> B[Renderer]
|
||||||
|
B --> C[Secure HTML]
|
||||||
|
```
|
||||||
|
|
||||||
|
Inline math: $E = mc^2$
|
||||||
|
|
||||||
|
Block math:
|
||||||
|
|
||||||
|
$$
|
||||||
|
\int_0^1 x^2 dx = \frac{1}{3}
|
||||||
|
$$
|
||||||
|
|
||||||
15
content/project-plan.md
Normal file
15
content/project-plan.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Project Plan
|
||||||
|
|
||||||
|
The implementation is moving in milestone order:
|
||||||
|
|
||||||
|
1. Foundation
|
||||||
|
2. Sync protocol
|
||||||
|
3. Authentication
|
||||||
|
4. Collaboration
|
||||||
|
5. Search and design
|
||||||
|
6. Production hardening
|
||||||
|
|
||||||
|
Current emphasis: #foundation #security
|
||||||
|
|
||||||
|
See [[Getting Started]] for the currently working demo path.
|
||||||
|
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
MD_HUB_SERVER_ADDR: ":8080"
|
||||||
|
MD_HUB_DATABASE_PATH: "/workspace/data/db.sqlite"
|
||||||
|
MD_HUB_CONTENT_SOURCE_DIR: "/workspace/content"
|
||||||
|
MD_HUB_CONTENT_STORE_DIR: "/workspace/data/files"
|
||||||
|
MD_HUB_WEB_DIST_DIR: "/workspace/web-dist"
|
||||||
|
volumes:
|
||||||
|
- ./content:/workspace/content:ro
|
||||||
|
- ./data:/workspace/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://127.0.0.1:8080/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
3
go.work.sum
Normal file
3
go.work.sum
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
|
||||||
Reference in New Issue
Block a user