diff --git a/cmd/md-to-html/main.go b/cmd/md-to-html/main.go index badd8bc..ace3c45 100644 --- a/cmd/md-to-html/main.go +++ b/cmd/md-to-html/main.go @@ -1,12 +1,18 @@ package main import ( + "context" "flag" "fmt" "io" "os" + "os/signal" + "syscall" + "github.com/fserg/md-to-html/internal/converter" + "github.com/fserg/md-to-html/internal/server" "github.com/fserg/md-to-html/internal/version" + webtemplate "github.com/fserg/md-to-html/web/template" ) func main() { @@ -24,7 +30,7 @@ func run(args []string, stdout, stderr io.Writer) int { printUsage(stdout) return 0 case "serve": - return runServe(args[1:], stdout) + return runServe(args[1:], stdout, stderr) case "cli": return runCLI(args[1:], stdout, stderr) case "version": @@ -36,13 +42,45 @@ func run(args []string, stdout, stderr io.Writer) int { } } -func runServe(args []string, stdout io.Writer) int { +func runServe(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs.SetOutput(io.Discard) if err := fs.Parse(args); err != nil { return 2 } - fmt.Fprintln(stdout, "serve not implemented yet") + + if fs.NArg() != 0 { + fmt.Fprintln(stderr, "usage: md-to-html serve") + return 2 + } + + cfg, err := server.LoadConfig() + if err != nil { + fmt.Fprintf(stderr, "load config: %v\n", err) + return 1 + } + + conv, err := converter.New(webtemplate.FS) + if err != nil { + fmt.Fprintf(stderr, "load converter: %v\n", err) + return 1 + } + + srv, err := server.New(cfg, conv) + if err != nil { + fmt.Fprintf(stderr, "create server: %v\n", err) + return 1 + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := srv.Run(ctx); err != nil { + fmt.Fprintf(stderr, "run server: %v\n", err) + return 1 + } + + _ = stdout return 0 } @@ -85,7 +123,7 @@ func printUsage(w io.Writer) { md-to-html version Commands: - serve Start the HTTP server stub + serve Start the HTTP server cli Convert a Markdown file stub version Print the build version `) diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..bbc976d --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,94 @@ +package server + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +const ( + defaultAddr = ":8080" + defaultMaxMarkdownBytes = int64(1_048_576) + defaultMaxRequestBytes = int64(1_200_000) + defaultPreviewTTL = time.Hour + defaultShutdownTimeout = 10 * time.Second +) + +type Config struct { + Addr string + MaxMarkdownBytes int64 + MaxRequestBytes int64 + PreviewTTL time.Duration + ShutdownTimeout time.Duration +} + +func LoadConfig() (Config, error) { + maxMarkdownBytes, err := loadPositiveInt64("MAX_MARKDOWN_BYTES", defaultMaxMarkdownBytes) + if err != nil { + return Config{}, err + } + + maxRequestBytes, err := loadPositiveInt64("MAX_REQUEST_BYTES", defaultMaxRequestBytes) + if err != nil { + return Config{}, err + } + + previewTTL, err := loadDuration("PREVIEW_TTL", defaultPreviewTTL) + if err != nil { + return Config{}, err + } + + shutdownTimeout, err := loadDuration("SHUTDOWN_TIMEOUT", defaultShutdownTimeout) + if err != nil { + return Config{}, err + } + + addr := strings.TrimSpace(os.Getenv("ADDR")) + if addr == "" { + addr = defaultAddr + } + + return Config{ + Addr: addr, + MaxMarkdownBytes: maxMarkdownBytes, + MaxRequestBytes: maxRequestBytes, + PreviewTTL: previewTTL, + ShutdownTimeout: shutdownTimeout, + }, nil +} + +func loadPositiveInt64(name string, fallback int64) (int64, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", name, err) + } + if value <= 0 { + return 0, fmt.Errorf("%s must be positive", name) + } + + return value, nil +} + +func loadDuration(name string, fallback time.Duration) (time.Duration, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + + value, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("%s must be a valid duration: %w", name, err) + } + if value <= 0 { + return 0, fmt.Errorf("%s must be positive", name) + } + + return value, nil +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..809f4c1 --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,248 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "strings" + + "github.com/fserg/md-to-html/internal/converter" + "github.com/fserg/md-to-html/internal/version" + "github.com/go-chi/chi/v5" +) + +const defaultDocumentTitle = "Document" + +type Server struct { + cfg Config + conv *converter.Converter + store *PreviewStore + log *slog.Logger +} + +type convertRequest struct { + Markdown string `json:"markdown"` + Title string `json:"title,omitempty"` +} + +func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) { + if !hasJSONContentType(r.Header.Get("Content-Type")) { + writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{ + "detail": "content-type must be application/json", + }) + return + } + + var payload convertRequest + if err := decodeJSON(r, &payload); err != nil { + s.writeDecodeError(w, err) + return + } + + result, err := s.convertMarkdown(payload.Markdown, payload.Title) + if err != nil { + s.writeConvertError(w, err) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(result.HTML) +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"version": version.Version}) +} + +func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "template_loaded": s.conv != nil, + }) +} + +func (s *Server) handleHome(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("UI coming in phase 4")) +} + +func (s *Server) handleUIConvert(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.writeDecodeError(w, err) + return + } + + result, err := s.convertMarkdown(r.Form.Get("markdown"), r.Form.Get("title")) + if err != nil { + s.writeConvertError(w, err) + return + } + + filename := htmlFilename(result.Title) + previewID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename) + downloadID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename) + + fragment := fmt.Sprintf( + `
`, + previewID, + downloadID, + ) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(fragment)) +} + +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + item, ok := s.store.Take(id) + if !ok { + http.NotFound(w, r) + return + } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", contentTypeOrDefault(item.mime)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(item.html) +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + item, ok := s.store.Take(id) + if !ok { + http.NotFound(w, r) + return + } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", contentTypeOrDefault(item.mime)) + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{ + "filename": item.filename, + })) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(item.html) +} + +func (s *Server) convertMarkdown(markdown, title string) (converter.Result, error) { + if strings.TrimSpace(markdown) == "" { + return converter.Result{}, errEmptyMarkdown + } + + if int64(len([]byte(markdown))) > s.cfg.MaxMarkdownBytes { + return converter.Result{}, errMarkdownTooLarge{limit: s.cfg.MaxMarkdownBytes} + } + + fallbackTitle := strings.TrimSpace(title) + if fallbackTitle == "" { + fallbackTitle = defaultDocumentTitle + } + + result, err := s.conv.Convert([]byte(markdown), fallbackTitle) + if err != nil { + return converter.Result{}, fmt.Errorf("convert markdown: %w", err) + } + + return result, nil +} + +func (s *Server) writeDecodeError(w http.ResponseWriter, err error) { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{ + "detail": fmt.Sprintf("request exceeds %d bytes", s.cfg.MaxRequestBytes), + }) + return + } + + writeJSON(w, http.StatusBadRequest, map[string]string{"detail": "invalid request payload"}) +} + +func (s *Server) writeConvertError(w http.ResponseWriter, err error) { + var markdownTooLarge errMarkdownTooLarge + switch { + case errors.Is(err, errEmptyMarkdown): + writeJSON(w, http.StatusBadRequest, map[string]string{"detail": err.Error()}) + case errors.As(err, &markdownTooLarge): + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{ + "detail": markdownTooLarge.Error(), + }) + default: + s.log.Error("convert_failed", "error", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"detail": err.Error()}) + } +} + +func hasJSONContentType(value string) bool { + mediaType, _, err := mime.ParseMediaType(value) + return err == nil && mediaType == "application/json" +} + +func decodeJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + + var extra json.RawMessage + if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) { + return err + } + + if len(extra) > 0 { + return errors.New("unexpected trailing JSON data") + } + + return nil +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(payload) +} + +func htmlFilename(title string) string { + name := strings.TrimSpace(title) + if name == "" { + name = "document" + } + + replacer := strings.NewReplacer("/", "-", "\\", "-", "\"", "", "\n", " ", "\r", " ") + name = strings.TrimSpace(replacer.Replace(name)) + if name == "" { + name = "document" + } + + return name + ".html" +} + +func contentTypeOrDefault(value string) string { + if strings.TrimSpace(value) == "" { + return "text/html; charset=utf-8" + } + return value +} + +var errEmptyMarkdown = errors.New("markdown must not be empty") + +type errMarkdownTooLarge struct { + limit int64 +} + +func (e errMarkdownTooLarge) Error() string { + return fmt.Sprintf("markdown exceeds %d bytes", e.limit) +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..504b3da --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,59 @@ +package server + +import ( + "log/slog" + "net/http" + "time" + + chimiddleware "github.com/go-chi/chi/v5/middleware" +) + +func MaxBytesMiddleware(limit int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, limit) + } + next.ServeHTTP(w, r) + }) + } +} + +func CORSMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers := w.Header() + headers.Set("Access-Control-Allow-Origin", "*") + headers.Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + headers.Set("Access-Control-Allow-Headers", "content-type") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func RequestLogger(log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor) + start := time.Now() + + next.ServeHTTP(ww, r) + + log.Info( + "http_request", + "request_id", chimiddleware.GetReqID(r.Context()), + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "bytes", ww.BytesWritten(), + "duration", time.Since(start), + ) + }) + } +} diff --git a/internal/server/preview_store.go b/internal/server/preview_store.go new file mode 100644 index 0000000..e5cbb8f --- /dev/null +++ b/internal/server/preview_store.go @@ -0,0 +1,91 @@ +package server + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" +) + +const janitorInterval = 5 * time.Minute + +type PreviewStore struct { + mu sync.Mutex + items map[string]previewItem + ttl time.Duration + now func() time.Time +} + +type previewItem struct { + html []byte + mime string + filename string + expires time.Time +} + +func NewPreviewStore(ttl time.Duration) *PreviewStore { + return &PreviewStore{ + items: make(map[string]previewItem), + ttl: ttl, + now: time.Now, + } +} + +func (s *PreviewStore) Put(html []byte, mime, filename string) string { + s.mu.Lock() + defer s.mu.Unlock() + + id := uuid.NewString() + s.items[id] = previewItem{ + html: append([]byte(nil), html...), + mime: mime, + filename: filename, + expires: s.now().Add(s.ttl), + } + + return id +} + +func (s *PreviewStore) Take(id string) (previewItem, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + item, ok := s.items[id] + if !ok { + return previewItem{}, false + } + + delete(s.items, id) + if s.now().After(item.expires) { + return previewItem{}, false + } + + item.html = append([]byte(nil), item.html...) + return item, true +} + +func (s *PreviewStore) janitor(ctx context.Context) { + ticker := time.NewTicker(janitorInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + s.cleanupExpired(now) + } + } +} + +func (s *PreviewStore) cleanupExpired(now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + for id, item := range s.items { + if now.After(item.expires) { + delete(s.items, id) + } + } +} diff --git a/internal/server/preview_store_test.go b/internal/server/preview_store_test.go new file mode 100644 index 0000000..2d80aeb --- /dev/null +++ b/internal/server/preview_store_test.go @@ -0,0 +1,80 @@ +package server + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestPreviewStore_OneShot(t *testing.T) { + t.Parallel() + + store := NewPreviewStore(time.Hour) + id := store.Put([]byte("