Files
ackify-ce/backend/pkg/web/static.go
Benjamin 24e2de2922 refactor(arch): enforce strict layered architecture with private interfaces
Apply Clean Architecture principles throughout the codebase to eliminate tight coupling between layers. Handlers now depend exclusively on services through private interfaces, never directly on repositories.
Introduce a ServerBuilder pattern with pluggable capability providers.

refactor(auth): introduce injectable AuthorizerService

Replace hardcoded AdminEmails and OnlyAdminCanCreate config fields
with an injectable AuthorizerService. This improves testability and
follows the dependency injection pattern used elsewhere in the codebase.

- Create AuthorizerService in application/services/
- Define minimal Authorizer interfaces in consuming packages
- Update middleware, handlers, and router to use injected authorizer
- Update all affected tests with mock implementations

refactor(build): move go.mod to backend directory
Move Go module files from project root to backend/ directory while keeping the module name as github.com/btouchard/ackify-ce.
This improves project structure by keeping Go-specific files within the Go codebase directory.

# Conflicts:
#	backend/internal/application/services/checksum_service_test.go
#	backend/internal/application/services/document_service.go
#	backend/internal/application/services/document_service_duplicate_test.go
#	backend/internal/application/services/document_service_test.go
#	backend/internal/presentation/api/documents/handler.go
#	backend/internal/presentation/api/documents/handler_test.go
#	backend/internal/presentation/api/router.go
#	backend/pkg/web/server.go
2025-12-08 16:07:03 +01:00

188 lines
6.8 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import (
"bytes"
"context"
"embed"
"fmt"
"html"
"io"
"io/fs"
"net/http"
"path"
"strings"
"github.com/btouchard/ackify-ce/internal/domain/models"
"github.com/btouchard/ackify-ce/pkg/logger"
)
// SignatureRepository defines minimal signature operations for meta tags
type SignatureRepository interface {
GetByDoc(ctx context.Context, docID string) ([]*models.Signature, error)
}
// EmbedFolder returns an http.HandlerFunc that serves an embedded filesystem
// with SPA fallback support (serves index.html for non-existent routes)
// For index.html, it replaces __ACKIFY_BASE_URL__ placeholder with the actual base URL,
// __ACKIFY_VERSION__ with the application version,
// __ACKIFY_OAUTH_ENABLED__ and __ACKIFY_MAGICLINK_ENABLED__ with auth method flags,
// __ACKIFY_SMTP_ENABLED__ with SMTP availability flag,
// __ACKIFY_ONLY_ADMIN_CAN_CREATE__ with document creation restriction flag,
// and __META_TAGS__ with dynamic meta tags based on query parameters
func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, smtpEnabled bool, onlyAdminCanCreate bool, signatureRepo SignatureRepository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fsys, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
logger.Logger.Error("Failed to load embedded files",
"target_path", targetPath,
"error", err.Error())
http.Error(w, "Failed to load embedded files", http.StatusInternalServerError)
return
}
urlPath := r.URL.Path
cleanPath := path.Clean(urlPath)
shouldServeIndex := false
if cleanPath == "/" {
cleanPath = "index.html"
shouldServeIndex = true
} else {
cleanPath = cleanPath[1:]
}
file, err := fsys.Open(cleanPath)
if err != nil {
logger.Logger.Debug("SPA fallback: file not found, serving index.html",
"requested_path", urlPath,
"clean_path", cleanPath)
cleanPath = "index.html"
shouldServeIndex = true
file, err = fsys.Open(cleanPath)
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
return
}
}
defer file.Close()
if shouldServeIndex || strings.HasSuffix(cleanPath, "index.html") {
serveIndexTemplate(w, r, file, baseURL, version, oauthEnabled, magicLinkEnabled, smtpEnabled, onlyAdminCanCreate, signatureRepo)
return
}
fileServer := http.FileServer(http.FS(fsys))
fileServer.ServeHTTP(w, r)
}
}
func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, smtpEnabled bool, onlyAdminCanCreate bool, signatureRepo SignatureRepository) {
content, err := io.ReadAll(file)
if err != nil {
logger.Logger.Error("Failed to read index.html", "error", err.Error())
http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
return
}
processedContent := strings.ReplaceAll(string(content), "__ACKIFY_BASE_URL__", baseURL)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_VERSION__", version)
// Convert boolean to string for JavaScript
oauthEnabledStr := "false"
if oauthEnabled {
oauthEnabledStr = "true"
}
magicLinkEnabledStr := "false"
if magicLinkEnabled {
magicLinkEnabledStr = "true"
}
smtpEnabledStr := "false"
if smtpEnabled {
smtpEnabledStr = "true"
}
onlyAdminCanCreateStr := "false"
if onlyAdminCanCreate {
onlyAdminCanCreateStr = "true"
}
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_OAUTH_ENABLED__", oauthEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_MAGICLINK_ENABLED__", magicLinkEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_SMTP_ENABLED__", smtpEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_ONLY_ADMIN_CAN_CREATE__", onlyAdminCanCreateStr)
metaTags := generateMetaTags(r, baseURL, signatureRepo)
processedContent = strings.ReplaceAll(processedContent, "__META_TAGS__", metaTags)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, bytes.NewBufferString(processedContent)); err != nil {
logger.Logger.Error("Failed to write response", "error", err.Error())
}
}
func generateMetaTags(r *http.Request, baseURL string, signatureRepo SignatureRepository) string {
docID := r.URL.Query().Get("doc")
if docID == "" {
return ""
}
ctx := context.Background()
signatures, err := signatureRepo.GetByDoc(ctx, docID)
if err != nil {
logger.Logger.Warn("Failed to fetch signatures for meta tags", "doc_id", docID, "error", err.Error())
return generateBasicMetaTags(docID, baseURL, 0)
}
signatureCount := len(signatures)
return generateBasicMetaTags(docID, baseURL, signatureCount)
}
func generateBasicMetaTags(docID string, baseURL string, signatureCount int) string {
escapedDocID := html.EscapeString(docID)
currentURL := fmt.Sprintf("%s/?doc=%s", baseURL, docID)
escapedURL := html.EscapeString(currentURL)
var title, description string
if signatureCount == 0 {
title = fmt.Sprintf("Document: %s - Aucune confirmation", escapedDocID)
description = fmt.Sprintf("Confirmations de lecture pour le document %s", escapedDocID)
} else if signatureCount == 1 {
title = fmt.Sprintf("Document: %s - 1 confirmation", escapedDocID)
description = fmt.Sprintf("1 personne a confirmé avoir lu le document %s", escapedDocID)
} else {
title = fmt.Sprintf("Document: %s - %d confirmations", escapedDocID, signatureCount)
description = fmt.Sprintf("%d personnes ont confirmé avoir lu le document %s", signatureCount, escapedDocID)
}
var metaTags strings.Builder
// Open Graph tags
metaTags.WriteString(fmt.Sprintf(`<meta property="og:title" content="%s" />`, html.EscapeString(title)))
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta property="og:description" content="%s" />`, html.EscapeString(description)))
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta property="og:url" content="%s" />`, escapedURL))
metaTags.WriteString("\n ")
metaTags.WriteString(`<meta property="og:type" content="website" />`)
metaTags.WriteString("\n ")
// Twitter Card tags
metaTags.WriteString(`<meta name="twitter:card" content="summary" />`)
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, html.EscapeString(title)))
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, html.EscapeString(description)))
metaTags.WriteString("\n ")
// oEmbed discovery tag
oembedURL := fmt.Sprintf("%s/oembed?url=%s", baseURL, escapedURL)
metaTags.WriteString(fmt.Sprintf(`<link rel="alternate" type="application/json+oembed" href="%s" title="%s" />`,
html.EscapeString(oembedURL),
html.EscapeString(title)))
return metaTags.String()
}