5bb488ccd0
release / release (push) Has been cancelled
build / test (push) Successful in 11m1s
build / cross-compile (amd64, linux) (push) Failing after 5m43s
build / cross-compile (arm64, darwin) (push) Failing after 5m23s
build / cross-compile (arm64, linux) (push) Failing after 5m23s
319 lines
8.7 KiB
Go
319 lines
8.7 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fserg/md-to-html/internal/converter"
|
|
"github.com/fserg/md-to-html/internal/ui"
|
|
"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, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = ui.Home().Render(r.Context(), w)
|
|
}
|
|
|
|
func (s *Server) handleUIConvert(w http.ResponseWriter, r *http.Request) {
|
|
startedAt := time.Now()
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxRequestBytes)
|
|
if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil {
|
|
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, "Слишком большой файл или ошибка формы")
|
|
return
|
|
}
|
|
|
|
md, filename, err := s.readUIMarkdownPayload(r)
|
|
if err != nil {
|
|
s.renderUIReadError(w, r, err)
|
|
return
|
|
}
|
|
|
|
result, err := s.conv.Convert(md, defaultDocumentTitle)
|
|
if err != nil {
|
|
s.log.Error("ui_convert_failed", "error", err)
|
|
s.renderUIError(w, r, http.StatusBadGateway, "Ошибка конвертации: "+err.Error())
|
|
return
|
|
}
|
|
|
|
previewID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
|
downloadID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
|
lineCount := bytes.Count(result.HTML, []byte("\n")) + 1
|
|
elapsedMs := int(time.Since(startedAt).Milliseconds())
|
|
if elapsedMs < 1 {
|
|
elapsedMs = 1
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = ui.Result(previewID, downloadID, string(result.HTML), filename, len(result.HTML), lineCount, elapsedMs).Render(r.Context(), w)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s *Server) renderUIError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = ui.Error(msg).Render(r.Context(), w)
|
|
}
|
|
|
|
func (s *Server) renderUIReadError(w http.ResponseWriter, r *http.Request, err error) {
|
|
var markdownTooLarge errMarkdownTooLarge
|
|
|
|
switch {
|
|
case errors.Is(err, errEmptyMarkdown):
|
|
s.renderUIError(w, r, http.StatusBadRequest, "Пустой markdown")
|
|
case errors.As(err, &markdownTooLarge):
|
|
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, fmt.Sprintf("Markdown больше %d байт", s.cfg.MaxMarkdownBytes))
|
|
default:
|
|
s.renderUIError(w, r, http.StatusBadRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
func (s *Server) readUIMarkdownPayload(r *http.Request) ([]byte, string, error) {
|
|
switch r.FormValue("source") {
|
|
case "", "file":
|
|
file, header, err := r.FormFile("markdown_file")
|
|
if err != nil {
|
|
return nil, "", errors.New("Файл не загружен")
|
|
}
|
|
defer file.Close()
|
|
|
|
markdown, err := io.ReadAll(io.LimitReader(file, s.cfg.MaxMarkdownBytes+1))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("не удалось прочитать файл: %w", err)
|
|
}
|
|
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
name := strings.TrimSpace(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
|
return markdown, htmlFilename(name), nil
|
|
case "text":
|
|
markdown := []byte(r.FormValue("markdown_text"))
|
|
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return markdown, "document.html", nil
|
|
default:
|
|
return nil, "", errors.New("Неизвестный источник markdown")
|
|
}
|
|
}
|
|
|
|
func validateMarkdown(markdown []byte, limit int64) error {
|
|
if int64(len(markdown)) > limit {
|
|
return errMarkdownTooLarge{limit: limit}
|
|
}
|
|
if len(bytes.TrimSpace(markdown)) == 0 {
|
|
return errEmptyMarkdown
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
}
|