427 lines
10 KiB
Go
427 lines
10 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"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 {
|
|
Browser browserData
|
|
}
|
|
|
|
type documentData struct {
|
|
Browser browserData
|
|
Title string
|
|
Path string
|
|
Tags []string
|
|
Hash string
|
|
HTML template.HTML
|
|
}
|
|
|
|
type errorData struct {
|
|
Status int
|
|
Message string
|
|
}
|
|
|
|
type browserData struct {
|
|
Columns []browserColumn
|
|
}
|
|
|
|
type browserColumn struct {
|
|
Title string
|
|
Items []browserItem
|
|
}
|
|
|
|
type browserItem struct {
|
|
Name string
|
|
Path string
|
|
URL string
|
|
IsFolder bool
|
|
IsIndex bool
|
|
HasIndex bool
|
|
Active bool
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
|
|
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
|
|
Title: "MD Hub Secure",
|
|
WebEnabled: s.webEnabled,
|
|
BodyClass: "page-index",
|
|
BodyTemplate: "index_content",
|
|
Data: indexData{
|
|
Browser: buildBrowser(items, activeFolder),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
|
|
s.renderDocumentPage(w, r, "")
|
|
}
|
|
|
|
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
|
pagePath := chi.URLParam(r, "*")
|
|
s.renderDocumentPage(w, r, pagePath)
|
|
}
|
|
|
|
func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, pagePath string) {
|
|
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
|
|
}
|
|
|
|
items, err := s.documents.ListDocuments(r.Context())
|
|
if err != nil {
|
|
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{
|
|
Browser: buildBrowser(items, page.Path),
|
|
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)
|
|
}
|
|
|
|
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
|
paths := make([]string, 0, len(records))
|
|
titleByPath := make(map[string]string, len(records))
|
|
indexByFolder := make(map[string]string)
|
|
for _, record := range records {
|
|
paths = append(paths, record.Path)
|
|
titleByPath[record.Path] = record.Title
|
|
if folder, ok := indexFolder(record.Path); ok {
|
|
indexByFolder[folder] = record.Path
|
|
}
|
|
}
|
|
|
|
prefixes := []string{""}
|
|
if activePath != "" {
|
|
parts := strings.Split(activePath, "/")
|
|
limit := len(parts)
|
|
if strings.HasSuffix(activePath, ".md") {
|
|
limit = len(parts) - 1
|
|
}
|
|
for i := 1; i <= limit; i++ {
|
|
prefixes = append(prefixes, strings.Join(parts[:i], "/"))
|
|
}
|
|
}
|
|
|
|
columns := make([]browserColumn, 0, len(prefixes))
|
|
for _, prefix := range prefixes {
|
|
column := browserColumn{
|
|
Title: columnTitle(prefix),
|
|
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
|
|
}
|
|
if len(column.Items) > 0 {
|
|
columns = append(columns, column)
|
|
}
|
|
}
|
|
|
|
return browserData{Columns: columns}
|
|
}
|
|
|
|
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
|
|
seenFolders := make(map[string]browserItem)
|
|
files := make([]browserItem, 0)
|
|
prefixWithSlash := ""
|
|
if prefix != "" {
|
|
prefixWithSlash = prefix + "/"
|
|
}
|
|
|
|
for _, path := range paths {
|
|
if shouldHideIndexFile(path, prefix) {
|
|
continue
|
|
}
|
|
|
|
if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) {
|
|
continue
|
|
}
|
|
|
|
remainder := strings.TrimPrefix(path, prefixWithSlash)
|
|
if remainder == "" {
|
|
continue
|
|
}
|
|
|
|
if slash := strings.Index(remainder, "/"); slash >= 0 {
|
|
name := remainder[:slash]
|
|
folderPath := name
|
|
if prefix != "" {
|
|
folderPath = prefix + "/" + name
|
|
}
|
|
indexPath, hasIndex := indexByFolder[folderPath]
|
|
url := "/?folder=" + folderPath
|
|
if hasIndex {
|
|
url = "/docs/" + strings.TrimSuffix(indexPath, "/index.md")
|
|
}
|
|
seenFolders[folderPath] = browserItem{
|
|
Name: displayFolderName(name, indexPath, titleByPath),
|
|
Path: folderPath,
|
|
URL: url,
|
|
IsFolder: true,
|
|
HasIndex: hasIndex,
|
|
Active: activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"),
|
|
}
|
|
continue
|
|
}
|
|
|
|
files = append(files, browserItem{
|
|
Name: displayDocumentName(path, titleByPath[path]),
|
|
Path: path,
|
|
URL: "/docs/" + strings.TrimSuffix(path, ".md"),
|
|
IsIndex: path == "index.md" || strings.HasSuffix(path, "/index.md"),
|
|
Active: activePath == path,
|
|
})
|
|
}
|
|
|
|
folders := make([]browserItem, 0, len(seenFolders))
|
|
for _, item := range seenFolders {
|
|
folders = append(folders, item)
|
|
}
|
|
sort.Slice(folders, func(i, j int) bool {
|
|
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
|
|
})
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
|
})
|
|
|
|
items := append(folders, files...)
|
|
return items
|
|
}
|
|
|
|
func columnTitle(prefix string) string {
|
|
if prefix == "" {
|
|
return "Content"
|
|
}
|
|
return filepath.Base(prefix)
|
|
}
|
|
|
|
func displayDocumentName(path string, title string) string {
|
|
if title != "" && title != "Untitled" {
|
|
return title
|
|
}
|
|
return strings.TrimSuffix(filepath.Base(path), ".md")
|
|
}
|
|
|
|
func displayFolderName(name string, indexPath string, titleByPath map[string]string) string {
|
|
if indexPath == "" {
|
|
return name
|
|
}
|
|
if title := titleByPath[indexPath]; title != "" && title != "Untitled" {
|
|
return title
|
|
}
|
|
return name
|
|
}
|
|
|
|
func indexFolder(path string) (string, bool) {
|
|
if path == "index.md" {
|
|
return "", true
|
|
}
|
|
if !strings.HasSuffix(path, "/index.md") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSuffix(path, "/index.md"), true
|
|
}
|
|
|
|
func shouldHideIndexFile(path string, prefix string) bool {
|
|
if path == "index.md" {
|
|
return false
|
|
}
|
|
if !strings.HasSuffix(path, "/index.md") {
|
|
return false
|
|
}
|
|
folder := strings.TrimSuffix(path, "/index.md")
|
|
return folder == prefix
|
|
}
|