Files
sysops 2bab61209c chore: Modulname github.com/archivmail → archivmail
Go-Modul in go.mod und allen 45 Go-Dateien umbenannt.
2026-04-05 20:37:35 +02:00

568 lines
14 KiB
Go

package api
import (
"archive/zip"
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"unicode"
"archivmail/internal/audit"
"archivmail/internal/userstore"
"archivmail/pkg/mailparser"
)
// ── Text helpers ──────────────────────────────────────────────────────────
// toLatinSafe converts German umlauts to ASCII digraphs and drops non-ASCII
// characters. Required for PDF Latin-1 encoding.
func toLatinSafe(s string) string {
replacer := strings.NewReplacer(
"ä", "ae", "ö", "oe", "ü", "ue",
"Ä", "Ae", "Ö", "Oe", "Ü", "Ue",
"ß", "ss",
)
s = replacer.Replace(s)
var b strings.Builder
for _, r := range s {
if r <= unicode.MaxASCII {
b.WriteRune(r)
}
}
return b.String()
}
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
var wsRe = regexp.MustCompile(`\s+`)
// stripHTML removes HTML tags and collapses whitespace.
func stripHTML(html string) string {
s := htmlTagRe.ReplaceAllString(html, " ")
s = wsRe.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
// formatBytesStr returns a human-readable file size string.
func formatBytesStr(n int) string {
switch {
case n >= 1024*1024:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
case n >= 1024:
return fmt.Sprintf("%.1f KB", float64(n)/1024)
default:
return fmt.Sprintf("%d B", n)
}
}
// ── Minimal PDF writer ────────────────────────────────────────────────────
//
// Generates a simple but valid PDF/1.4 document using only stdlib.
// Uses the standard 14 core fonts (Helvetica) which every viewer supports.
type pdfWriter struct {
buf bytes.Buffer
offsets []int // byte offsets of each object
}
func newPDFWriter() *pdfWriter { return &pdfWriter{} }
func (p *pdfWriter) write(s string) { p.buf.WriteString(s) }
func (p *pdfWriter) writef(f string, args ...interface{}) {
p.buf.WriteString(fmt.Sprintf(f, args...))
}
// startObj records the current byte offset and writes the object header.
func (p *pdfWriter) startObj(n int) {
// Grow offsets slice if needed
for len(p.offsets) < n {
p.offsets = append(p.offsets, 0)
}
p.offsets[n-1] = p.buf.Len()
p.writef("%d 0 obj\n", n)
}
func (p *pdfWriter) endObj() { p.write("endobj\n") }
// escapePDF escapes special characters in PDF string literals.
func escapePDF(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `(`, `\(`)
s = strings.ReplaceAll(s, `)`, `\)`)
s = strings.ReplaceAll(s, "\r", `\r`)
s = strings.ReplaceAll(s, "\n", `\n`)
return s
}
// wrapText wraps text at maxChars width, returning lines.
func wrapText(text string, maxChars int) []string {
if maxChars <= 0 {
maxChars = 80
}
var lines []string
for _, para := range strings.Split(text, "\n") {
words := strings.Fields(para)
if len(words) == 0 {
lines = append(lines, "")
continue
}
var cur strings.Builder
for _, w := range words {
if cur.Len() == 0 {
cur.WriteString(w)
} else if cur.Len()+1+len(w) <= maxChars {
cur.WriteByte(' ')
cur.WriteString(w)
} else {
lines = append(lines, cur.String())
cur.Reset()
cur.WriteString(w)
}
}
if cur.Len() > 0 {
lines = append(lines, cur.String())
}
}
return lines
}
// buildMailPDF produces the raw bytes of a PDF representing the given mail.
func buildMailPDF(id string, pm *mailparser.ParsedMail, rawSize int) []byte {
p := newPDFWriter()
// Page dimensions: A4 in points (72 pt/inch)
// 210mm x 297mm => 595.28 x 841.89 pt
pageW := 595.28
pageH := 841.89
margin := 50.0
lineH := 14.0
smallLineH := 12.0
contentW := pageW - 2*margin
// Prepare body content
body := pm.TextBody
if body == "" && pm.HTMLBody != "" {
body = stripHTML(pm.HTMLBody)
}
body = toLatinSafe(body)
bodyLines := wrapText(body, 85)
// Prepare header lines
var dateStr string
if !pm.Date.IsZero() {
dateStr = pm.Date.Format("02.01.2006 15:04:05")
}
type hline struct{ label, value string }
headers := []hline{
{"Von:", toLatinSafe(pm.From)},
{"An:", toLatinSafe(strings.Join(pm.To, ", "))},
{"CC:", toLatinSafe(strings.Join(pm.CC, ", "))},
{"Betreff:", toLatinSafe(pm.Subject)},
{"Datum:", dateStr},
{"Groesse:", formatBytesStr(rawSize)},
}
// Attachment lines
var attLines []string
for _, a := range pm.Attachments {
attLines = append(attLines, toLatinSafe(fmt.Sprintf(
" - %s [%s, %s]", a.Filename, a.ContentType, formatBytesStr(a.Size),
)))
}
// Build page content stream
var cs bytes.Buffer
y := pageH - margin
// Helper: move to next line
newline := func(h float64) { y -= h }
// BT block builder helper — writes text at absolute position
text := func(x, ypos float64, size float64, bold bool, s string) {
font := "Helvetica"
if bold {
font = "Helvetica-Bold"
}
cs.WriteString(fmt.Sprintf("BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n",
font, size, x, ypos, escapePDF(s)))
}
// Horizontal line
hline2 := func(ypos float64) {
cs.WriteString(fmt.Sprintf("%.1f %.1f m %.1f %.1f l S\n",
margin, ypos, pageW-margin, ypos))
}
// Title
text(margin, y, 16, true, "archivmail Export")
newline(lineH * 1.8)
// Header table
labelX := margin
valueX := margin + 70
for _, h := range headers {
text(labelX, y, 10, true, h.label)
// Wrap value if long
valLines := wrapText(h.value, 70)
for i, vl := range valLines {
text(valueX, y, 10, false, vl)
if i < len(valLines)-1 {
newline(smallLineH)
}
}
newline(smallLineH)
}
newline(4)
// Separator
hline2(y)
newline(lineH)
// Body
if len(bodyLines) > 0 {
for _, bl := range bodyLines {
if y < margin+20 {
// Simple overflow protection: stop rendering
text(margin, y, 8, false, "[... Text abgeschnitten ...]")
y = margin
break
}
text(margin, y, 9, false, bl)
newline(smallLineH)
}
} else {
text(margin, y, 9, false, "(kein Textinhalt)")
newline(smallLineH)
}
// Attachments section
if len(attLines) > 0 {
newline(6)
if y > margin+20 {
hline2(y)
newline(lineH)
text(margin, y, 10, true, fmt.Sprintf("Anhaenge (%d):", len(pm.Attachments)))
newline(lineH)
for _, al := range attLines {
if y < margin+20 {
break
}
text(margin, y, 9, false, al)
newline(smallLineH)
}
}
}
contentStream := cs.String()
// ── PDF objects ───────────────────────────────────────────────────────
// Obj 1: Catalog
// Obj 2: Pages
// Obj 3: Page
// Obj 4: Content stream
p.write("%PDF-1.4\n")
// Obj 1: Catalog
p.startObj(1)
p.write("<< /Type /Catalog /Pages 2 0 R >>\n")
p.endObj()
// Obj 2: Pages
p.startObj(2)
p.writef("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 %.2f %.2f] >>\n", pageW, pageH)
p.endObj()
// Obj 3: Page
p.startObj(3)
p.writef("<< /Type /Page /Parent 2 0 R\n")
p.writef(" /Resources << /Font << /Helvetica << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n")
p.writef(" /Helvetica-Bold << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >>\n")
p.writef(" /Contents 4 0 R\n")
p.writef(" /MediaBox [0 0 %.2f %.2f]\n", pageW, pageH)
p.write(">>\n")
p.endObj()
// Obj 4: Content stream
p.startObj(4)
// Set line width for horizontal rules
streamWithSetup := "0.5 w\n" + contentStream
streamBytes := []byte(streamWithSetup)
p.writef("<< /Length %d >>\n", len(streamBytes))
p.write("stream\n")
p.buf.Write(streamBytes)
p.write("\nendstream\n")
p.endObj()
// Cross-reference table
xrefOffset := p.buf.Len()
p.writef("xref\n0 5\n")
p.writef("0000000000 65535 f \n")
for i := 0; i < 4; i++ {
off := 0
if i < len(p.offsets) {
off = p.offsets[i]
}
p.writef("%010d 00000 n \n", off)
}
// Ensure contentW is used (suppress unused variable warning)
_ = contentW
p.write("trailer\n")
p.writef("<< /Size 5 /Root 1 0 R >>\n")
p.write("startxref\n")
p.writef("%d\n", xrefOffset)
p.write("%%EOF\n")
return p.buf.Bytes()
}
// ── PDF handler ───────────────────────────────────────────────────────────
func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")
return
}
sess := sessionFromCtx(r.Context())
// SEC-05: Tenant isolation for PDF export.
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// auditor: only mails with no tenant assignment.
if sess.Role == userstore.RoleAuditor {
ok, err := s.store.IsWithoutTenant(r.Context(), id)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// user: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
safeID := id
if len(safeID) > 16 {
safeID = safeID[:16]
}
filename := safeID + ".pdf"
pdfBytes := buildMailPDF(id, pm, len(raw))
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
w.WriteHeader(http.StatusOK)
w.Write(pdfBytes) //nolint:errcheck
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: sess.Username,
IPAddress: s.remoteIP(r),
MailID: id,
Detail: "pdf",
Success: true,
})
}
// ── ZIP export ────────────────────────────────────────────────────────────
func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.IDs) == 0 {
writeError(w, http.StatusBadRequest, "ids must not be empty")
return
}
if len(req.IDs) > 500 {
writeError(w, http.StatusBadRequest, "too many ids (max 500)")
return
}
// SEC-22: Validate all mail IDs before processing.
for _, id := range req.IDs {
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
}
sess := sessionFromCtx(r.Context())
// SEC-05: Tenant isolation — verify all requested IDs belong to caller's tenant.
if sess.TenantID != nil {
allowedIDs, err := s.store.GetAllIDsByTenant(r.Context(), sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "tenant check failed")
return
}
allowed := make(map[string]struct{}, len(allowedIDs))
for _, aid := range allowedIDs {
allowed[aid] = struct{}{}
}
for _, id := range req.IDs {
if _, ok := allowed[id]; !ok {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
}
// Auditor: pre-load the set of no-tenant mail IDs for efficient per-mail checks.
var auditorAllowed map[string]struct{}
if sess.Role == userstore.RoleAuditor {
ids, err := s.store.GetAllIDsWithoutTenant(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "tenant check failed")
return
}
auditorAllowed = make(map[string]struct{}, len(ids))
for _, aid := range ids {
auditorAllowed[aid] = struct{}{}
}
}
// User: look up email once for per-mail access checks.
var userEmail string
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil {
writeError(w, http.StatusInternalServerError, "user lookup failed")
return
}
userEmail = u.Email
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="archivmail-export.zip"`)
zw := zip.NewWriter(w)
defer zw.Close()
type manifestRow struct {
filename string
messageID string
from string
to string
subject string
date string
}
var manifest []manifestRow
exported := 0
for _, id := range req.IDs {
raw, err := s.store.Load(id)
if err != nil {
continue
}
pm, err := mailparser.Parse(raw)
if err != nil {
continue
}
// auditor: only mails with no tenant assignment.
if auditorAllowed != nil {
if _, ok := auditorAllowed[id]; !ok {
continue
}
}
// user: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser && !mailBelongsToUser(pm, userEmail) {
continue
}
safeID := id
if len(safeID) > 16 {
safeID = safeID[:16]
}
emlFilename := safeID + ".eml"
fw, err := zw.Create(emlFilename)
if err != nil {
continue
}
if _, err := fw.Write(raw); err != nil {
continue
}
var dateStr string
if !pm.Date.IsZero() {
dateStr = pm.Date.UTC().Format(time.RFC3339)
}
manifest = append(manifest, manifestRow{
filename: emlFilename,
messageID: pm.MessageID,
from: pm.From,
to: strings.Join(pm.To, ", "),
subject: pm.Subject,
date: dateStr,
})
exported++
}
// Write manifest.csv
mf, err := zw.Create("manifest.csv")
if err == nil {
cw := csv.NewWriter(mf)
cw.Write([]string{"filename", "message_id", "from", "to", "subject", "date"}) //nolint:errcheck
for _, row := range manifest {
cw.Write([]string{ //nolint:errcheck
row.filename,
row.messageID,
row.from,
row.to,
row.subject,
row.date,
})
}
cw.Flush()
}
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("zip: %d mails", exported),
Success: true,
})
}