|
|
|
@@ -0,0 +1,479 @@
|
|
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"archivmail/internal/audit"
|
|
|
|
|
"archivmail/internal/index"
|
|
|
|
|
"archivmail/internal/storage"
|
|
|
|
|
"archivmail/internal/userstore"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// dsgvoMaxHits caps the number of mails evaluated per request, mirroring the
|
|
|
|
|
// eDiscovery export limit (PROJ-39). Beyond this the admin is asked to narrow
|
|
|
|
|
// the date range.
|
|
|
|
|
const dsgvoMaxHits = 10000
|
|
|
|
|
|
|
|
|
|
// canManageDSGVO reports whether the role may create/process DSGVO requests.
|
|
|
|
|
func canManageDSGVO(role string) bool {
|
|
|
|
|
switch role {
|
|
|
|
|
case userstore.RoleSuperAdmin, userstore.RoleDomainAdmin, userstore.RoleAdmin:
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// canViewDSGVO reports whether the role may read DSGVO requests (incl. auditors).
|
|
|
|
|
func canViewDSGVO(role string) bool {
|
|
|
|
|
if canManageDSGVO(role) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
switch role {
|
|
|
|
|
case userstore.RoleAuditor, userstore.RoleDomainAuditor:
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleCreateDSGVORequest creates a DSGVO erasure request, runs the address
|
|
|
|
|
// search in the admin's tenant scope, evaluates retain_until per mail and
|
|
|
|
|
// stores the documented result.
|
|
|
|
|
//
|
|
|
|
|
// POST /api/admin/dsgvo
|
|
|
|
|
// Body: {address, date_from?, date_to?}
|
|
|
|
|
func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
|
if !canManageDSGVO(sess.Role) {
|
|
|
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
|
|
|
|
|
|
var req struct {
|
|
|
|
|
Address string `json:"address"`
|
|
|
|
|
DateFrom string `json:"date_from"`
|
|
|
|
|
DateTo string `json:"date_to"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
address := strings.ToLower(strings.TrimSpace(req.Address))
|
|
|
|
|
if address == "" || !strings.Contains(address, "@") {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "valid e-mail address required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build the address search across from/to/cc/bcc.
|
|
|
|
|
searchReq := index.SearchRequest{
|
|
|
|
|
AnyAddress: address,
|
|
|
|
|
PageSize: dsgvoMaxHits,
|
|
|
|
|
Page: 1,
|
|
|
|
|
}
|
|
|
|
|
if req.DateFrom != "" {
|
|
|
|
|
if t, err := time.Parse(time.DateOnly, req.DateFrom); err == nil {
|
|
|
|
|
searchReq.DateFrom = &t
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if req.DateTo != "" {
|
|
|
|
|
if t, err := time.Parse(time.DateOnly, req.DateTo); err == nil {
|
|
|
|
|
t = t.Add(24*time.Hour - time.Second)
|
|
|
|
|
searchReq.DateTo = &t
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchIdx := s.idx
|
|
|
|
|
if s.idxMgr != nil && tenantID != nil {
|
|
|
|
|
searchIdx = s.idxMgr.ForTenant(tenantID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result, err := searchIdx.Search(searchReq)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "search failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Persist the request first so the vorgang is documented even if later steps fail.
|
|
|
|
|
dsReq, err := s.store.CreateDSGVORequest(r.Context(), tenantID, address, sess.Username)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not create request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ids := make([]string, 0, len(result.Hits))
|
|
|
|
|
for _, h := range result.Hits {
|
|
|
|
|
ids = append(ids, h.ID)
|
|
|
|
|
}
|
|
|
|
|
meta, err := s.store.GetDSGVOMailMeta(r.Context(), ids)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "metadata lookup failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
summary := storage.DSGVOResultSummary{
|
|
|
|
|
Truncated: result.Total > dsgvoMaxHits,
|
|
|
|
|
}
|
|
|
|
|
for _, id := range ids {
|
|
|
|
|
m, ok := meta[id]
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
summary.TotalHits++
|
|
|
|
|
am := storage.DSGVOAffectedMail{
|
|
|
|
|
MailID: id,
|
|
|
|
|
Subject: m.Subject,
|
|
|
|
|
RetainUntil: m.RetainUntil,
|
|
|
|
|
}
|
|
|
|
|
if !m.ReceivedAt.IsZero() {
|
|
|
|
|
d := m.ReceivedAt
|
|
|
|
|
am.Date = &d
|
|
|
|
|
}
|
|
|
|
|
if m.RetainUntil != nil && now.Before(*m.RetainUntil) {
|
|
|
|
|
summary.Rejected++
|
|
|
|
|
am.Deletable = false
|
|
|
|
|
am.Reason = fmt.Sprintf("Abgelehnt - gesetzliche Aufbewahrungspflicht bis %s",
|
|
|
|
|
m.RetainUntil.Format("2006-01-02"))
|
|
|
|
|
} else {
|
|
|
|
|
summary.Deletable++
|
|
|
|
|
am.Deletable = true
|
|
|
|
|
am.Reason = "Loeschbar - keine Aufbewahrungspflicht"
|
|
|
|
|
}
|
|
|
|
|
summary.Mails = append(summary.Mails, am)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
status := dsgvoStatus(summary)
|
|
|
|
|
if err := s.store.UpdateDSGVOResult(r.Context(), dsReq.ID, status, &summary); err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not store result")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
dsReq.Status = status
|
|
|
|
|
dsReq.ResultSummary = &summary
|
|
|
|
|
|
|
|
|
|
if s.audlog != nil {
|
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
|
|
|
EventType: audit.EventDSGVORequest,
|
|
|
|
|
Username: sess.Username,
|
|
|
|
|
IPAddress: s.remoteIP(r),
|
|
|
|
|
Success: true,
|
|
|
|
|
Detail: fmt.Sprintf("dsgvo: address=%q hits=%d rejected=%d deletable=%d truncated=%t",
|
|
|
|
|
address, summary.TotalHits, summary.Rejected, summary.Deletable, summary.Truncated),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, dsReq)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dsgvoStatus computes the request status from the result summary.
|
|
|
|
|
func dsgvoStatus(s storage.DSGVOResultSummary) string {
|
|
|
|
|
if s.TotalHits == 0 {
|
|
|
|
|
return storage.DSGVOStatusCompleted
|
|
|
|
|
}
|
|
|
|
|
if s.Rejected > 0 && s.Deletable > 0 {
|
|
|
|
|
return storage.DSGVOStatusPartial
|
|
|
|
|
}
|
|
|
|
|
if s.Rejected > 0 {
|
|
|
|
|
return storage.DSGVOStatusPartial
|
|
|
|
|
}
|
|
|
|
|
return storage.DSGVOStatusCompleted
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleListDSGVORequests lists requests in the caller's tenant scope.
|
|
|
|
|
// GET /api/admin/dsgvo
|
|
|
|
|
func (s *Server) handleListDSGVORequests(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
|
if !canViewDSGVO(sess.Role) {
|
|
|
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
|
list, err := s.store.ListDSGVORequests(r.Context(), tenantID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "list failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"requests": list})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleGetDSGVORequest returns a single request detail.
|
|
|
|
|
// GET /api/admin/dsgvo/{id}
|
|
|
|
|
func (s *Server) handleGetDSGVORequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
|
if !canViewDSGVO(sess.Role) {
|
|
|
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
|
req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, req)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleDeleteDSGVOMails deletes the deletable mails of a request using the
|
|
|
|
|
// existing retention-aware delete mechanism (PROJ-34). Mails under retention
|
|
|
|
|
// lock are skipped. Each deletion is documented and the request summary is
|
|
|
|
|
// updated to reflect what was actually removed.
|
|
|
|
|
//
|
|
|
|
|
// POST /api/admin/dsgvo/{id}/delete
|
|
|
|
|
func (s *Server) handleDeleteDSGVOMails(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
|
if !canManageDSGVO(sess.Role) {
|
|
|
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
|
req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID)
|
|
|
|
|
if err != nil || req.ResultSummary == nil {
|
|
|
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchIdx := s.idx
|
|
|
|
|
if s.idxMgr != nil && tenantID != nil {
|
|
|
|
|
searchIdx = s.idxMgr.ForTenant(tenantID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleted := 0
|
|
|
|
|
for i := range req.ResultSummary.Mails {
|
|
|
|
|
m := &req.ResultSummary.Mails[i]
|
|
|
|
|
if !m.Deletable || m.Deleted {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.Delete(m.MailID); err != nil {
|
|
|
|
|
// Retention lock or missing file → leave as-is, keep documented.
|
|
|
|
|
m.Reason = "Loeschung nicht moeglich: " + err.Error()
|
|
|
|
|
m.Deletable = false
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
_ = searchIdx.Delete(m.MailID)
|
|
|
|
|
m.Deleted = true
|
|
|
|
|
m.Deletable = false
|
|
|
|
|
m.Reason = "Geloescht auf DSGVO-Antrag"
|
|
|
|
|
deleted++
|
|
|
|
|
|
|
|
|
|
if s.audlog != nil {
|
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
|
|
|
EventType: audit.EventDSGVORequest,
|
|
|
|
|
Username: sess.Username,
|
|
|
|
|
IPAddress: s.remoteIP(r),
|
|
|
|
|
MailID: m.MailID,
|
|
|
|
|
Success: true,
|
|
|
|
|
Detail: fmt.Sprintf("dsgvo: deleted mail (request_id=%d address=%q)", id, req.RequestedAddress),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Recompute counters.
|
|
|
|
|
req.ResultSummary.Deleted += deleted
|
|
|
|
|
req.ResultSummary.Deletable = 0
|
|
|
|
|
for _, m := range req.ResultSummary.Mails {
|
|
|
|
|
if m.Deletable {
|
|
|
|
|
req.ResultSummary.Deletable++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
status := dsgvoStatus(*req.ResultSummary)
|
|
|
|
|
if req.ResultSummary.Deleted > 0 && req.ResultSummary.Deletable == 0 && req.ResultSummary.Rejected == 0 {
|
|
|
|
|
status = storage.DSGVOStatusCompleted
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.UpdateDSGVOResult(r.Context(), id, status, req.ResultSummary); err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not update request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.Status = status
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"deleted": deleted, "request": req})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleExportDSGVORequest produces a PDF summary suitable as a written reply
|
|
|
|
|
// to the data subject, documenting the legal basis for any rejection.
|
|
|
|
|
//
|
|
|
|
|
// GET /api/admin/dsgvo/{id}/export
|
|
|
|
|
func (s *Server) handleExportDSGVORequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
|
if !canViewDSGVO(sess.Role) {
|
|
|
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
|
req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pdf := buildDSGVOPDF(req)
|
|
|
|
|
filename := fmt.Sprintf("dsgvo-antrag-%d.pdf", req.ID)
|
|
|
|
|
w.Header().Set("Content-Type", "application/pdf")
|
|
|
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
|
|
|
_, _ = w.Write(pdf)
|
|
|
|
|
|
|
|
|
|
if s.audlog != nil {
|
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
|
|
|
EventType: audit.EventExport,
|
|
|
|
|
Username: sess.Username,
|
|
|
|
|
IPAddress: s.remoteIP(r),
|
|
|
|
|
Success: true,
|
|
|
|
|
Detail: fmt.Sprintf("dsgvo: export request_id=%d", req.ID),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildDSGVOPDF renders a DSGVO request summary as a PDF using the existing
|
|
|
|
|
// minimal PDF writer (PROJ-12 buildMailPDF pattern).
|
|
|
|
|
func buildDSGVOPDF(req *storage.DSGVORequest) []byte {
|
|
|
|
|
p := newPDFWriter()
|
|
|
|
|
pageW := 595.28
|
|
|
|
|
pageH := 841.89
|
|
|
|
|
margin := 50.0
|
|
|
|
|
lineH := 14.0
|
|
|
|
|
smallLineH := 12.0
|
|
|
|
|
|
|
|
|
|
var cs bytes.Buffer
|
|
|
|
|
y := pageH - margin
|
|
|
|
|
newline := func(h float64) { y -= h }
|
|
|
|
|
text := func(x, ypos, 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(toLatinSafe(s))))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
text(margin, y, 16, true, "DSGVO-Loeschersuchen - Bearbeitungsnachweis")
|
|
|
|
|
newline(lineH * 2)
|
|
|
|
|
|
|
|
|
|
text(margin, y, 10, true, "Antrags-ID:")
|
|
|
|
|
text(margin+120, y, 10, false, strconv.FormatInt(req.ID, 10))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin, y, 10, true, "Betroffene Adresse:")
|
|
|
|
|
text(margin+120, y, 10, false, req.RequestedAddress)
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin, y, 10, true, "Bearbeitet von:")
|
|
|
|
|
text(margin+120, y, 10, false, req.RequestedBy)
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin, y, 10, true, "Erstellt am:")
|
|
|
|
|
text(margin+120, y, 10, false, req.CreatedAt.Format("02.01.2006 15:04:05"))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin, y, 10, true, "Status:")
|
|
|
|
|
text(margin+120, y, 10, false, req.Status)
|
|
|
|
|
newline(lineH * 1.5)
|
|
|
|
|
|
|
|
|
|
if req.ResultSummary != nil {
|
|
|
|
|
sum := req.ResultSummary
|
|
|
|
|
text(margin, y, 12, true, "Zusammenfassung")
|
|
|
|
|
newline(lineH)
|
|
|
|
|
text(margin, y, 10, false, fmt.Sprintf("Treffer gesamt: %d", sum.TotalHits))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin, y, 10, false, fmt.Sprintf("Abgelehnt (Aufbewahrungspflicht): %d", sum.Rejected))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin, y, 10, false, fmt.Sprintf("Loeschbar: %d", sum.Deletable))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin, y, 10, false, fmt.Sprintf("Bereits geloescht: %d", sum.Deleted))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
if sum.Truncated {
|
|
|
|
|
text(margin, y, 9, false, fmt.Sprintf("Hinweis: Ergebnis auf %d Treffer begrenzt - Zeitraum eingrenzen.", dsgvoMaxHits))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
}
|
|
|
|
|
newline(6)
|
|
|
|
|
|
|
|
|
|
text(margin, y, 12, true, "Begruendung der Rechtslage")
|
|
|
|
|
newline(lineH)
|
|
|
|
|
legal := "Soweit E-Mails einer gesetzlichen Aufbewahrungspflicht (GoBD/HGB/AO) " +
|
|
|
|
|
"unterliegen, ueberwiegt diese den Loeschanspruch nach Art. 17 DSGVO. " +
|
|
|
|
|
"Eine Loeschung erfolgt erst nach Ablauf der jeweiligen Aufbewahrungsfrist."
|
|
|
|
|
for _, l := range wrapText(legal, 90) {
|
|
|
|
|
text(margin, y, 9, false, l)
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
}
|
|
|
|
|
newline(6)
|
|
|
|
|
|
|
|
|
|
text(margin, y, 12, true, "Betroffene E-Mails")
|
|
|
|
|
newline(lineH)
|
|
|
|
|
for _, m := range sum.Mails {
|
|
|
|
|
if y < margin+30 {
|
|
|
|
|
text(margin, y, 8, false, "[... gekuerzt ...]")
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
dateStr := ""
|
|
|
|
|
if m.Date != nil {
|
|
|
|
|
dateStr = m.Date.Format("2006-01-02")
|
|
|
|
|
}
|
|
|
|
|
subj := m.Subject
|
|
|
|
|
if len(subj) > 60 {
|
|
|
|
|
subj = subj[:60]
|
|
|
|
|
}
|
|
|
|
|
text(margin, y, 9, false, fmt.Sprintf("- [%s] %s", dateStr, subj))
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
text(margin+12, y, 8, false, m.Reason)
|
|
|
|
|
newline(smallLineH)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contentStream := cs.String()
|
|
|
|
|
|
|
|
|
|
p.write("%PDF-1.4\n")
|
|
|
|
|
p.startObj(1)
|
|
|
|
|
p.write("<< /Type /Catalog /Pages 2 0 R >>\n")
|
|
|
|
|
p.endObj()
|
|
|
|
|
p.startObj(2)
|
|
|
|
|
p.writef("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 %.2f %.2f] >>\n", pageW, pageH)
|
|
|
|
|
p.endObj()
|
|
|
|
|
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()
|
|
|
|
|
p.startObj(4)
|
|
|
|
|
streamBytes := []byte("0.5 w\n" + contentStream)
|
|
|
|
|
p.writef("<< /Length %d >>\n", len(streamBytes))
|
|
|
|
|
p.write("stream\n")
|
|
|
|
|
p.buf.Write(streamBytes)
|
|
|
|
|
p.write("\nendstream\n")
|
|
|
|
|
p.endObj()
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
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()
|
|
|
|
|
}
|