init push
This commit is contained in:
511
internal/transport/http/handlers/office_xlsx_sanitizer.go
Normal file
511
internal/transport/http/handlers/office_xlsx_sanitizer.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
)
|
||||
|
||||
var (
|
||||
xlsxVMLStyleAttrRE = regexp.MustCompile(`(?i)(style\s*=\s*["'])([^"']*)(["'])`)
|
||||
xlsxVMLLengthTokenRE = regexp.MustCompile(`^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:pt|px|in|cm|mm|pc|em|ex|%)$`)
|
||||
xlsxVMLShapeTagRE = regexp.MustCompile(`(?is)<v:shape\b[^>]*>`)
|
||||
xlsxVMLShapeIDAttrRE = regexp.MustCompile(`(?i)(\bid\s*=\s*["'])(_x0000_s\d+)(["'])`)
|
||||
xlsxVMLShapeIDNumRE = regexp.MustCompile(`(?i)^_x0000_s(\d+)$`)
|
||||
xlsxVMLShapeTypeRE = regexp.MustCompile(`(?i)<v:shapetype\b`)
|
||||
xlsxVMLNoteTypeRefRE = regexp.MustCompile(`(?i)type\s*=\s*["']#_x0000_t202["']`)
|
||||
xlsxVMLRootTagRE = regexp.MustCompile(`(?is)<xml\b[^>]*>`)
|
||||
)
|
||||
|
||||
// xlsxVMLNoteShapeTypeHeader is the standard <o:shapelayout>/<v:shapetype>
|
||||
// preamble that Excel writes at the top of every comment/note VML drawing.
|
||||
// Some producers (and files that have been round-tripped through other tools)
|
||||
// omit it while still emitting shapes that reference type="#_x0000_t202";
|
||||
// LibreOffice/Collabora then flags the package as corrupt and offers a repair.
|
||||
// Injecting the missing definition makes the shape reference resolve and
|
||||
// removes the repair prompt.
|
||||
const xlsxVMLNoteShapeTypeHeader = `<o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout>` +
|
||||
`<v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe">` +
|
||||
`<v:stroke joinstyle="miter"/><v:path gradientshapeok="t" o:connecttype="rect"/></v:shapetype>`
|
||||
|
||||
type xlsxSanitizerStorage interface {
|
||||
GetObject(context.Context, string) (io.ReadCloser, filemanager.ObjectMetadata, error)
|
||||
PutObject(context.Context, filemanager.PutObjectInput) (filemanager.ObjectMetadata, error)
|
||||
}
|
||||
|
||||
type xlsxSanitizerFileUpdater interface {
|
||||
UpdateFile(context.Context, *filemanager.File) error
|
||||
}
|
||||
|
||||
func sanitizeXLSXPayload(payload []byte) ([]byte, int, bool, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, 0, false, errors.New("file content is not a valid office zip document")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := zip.NewWriter(&buf)
|
||||
if reader.Comment != "" {
|
||||
if err := writer.SetComment(reader.Comment); err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
totalStyleFixes := 0
|
||||
changed := false
|
||||
|
||||
for _, file := range reader.File {
|
||||
header := file.FileHeader
|
||||
header.Name = file.Name
|
||||
header.Comment = file.Comment
|
||||
header.NonUTF8 = file.NonUTF8
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
entry, err := writer.CreateHeader(&header)
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
if _, err := entry.Write(nil); err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !isVMLDrawingEntry(file.Name) {
|
||||
raw, err := file.OpenRaw()
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
entry, err := writer.CreateRaw(&header)
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
if _, err := io.Copy(entry, raw); err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
filePayload, readErr := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
if readErr != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, readErr
|
||||
}
|
||||
|
||||
sanitizedPayload, styleFixes, fileChanged, err := sanitizeVMLDrawingPayload(filePayload)
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
if dedupedPayload, idFixes, idChanged := dedupeVMLShapeIDs(sanitizedPayload); idChanged {
|
||||
sanitizedPayload = dedupedPayload
|
||||
fileChanged = true
|
||||
totalStyleFixes += idFixes
|
||||
}
|
||||
if shapeTypePayload, shapeTypeFixes, shapeTypeChanged := ensureVMLNoteShapeType(sanitizedPayload); shapeTypeChanged {
|
||||
sanitizedPayload = shapeTypePayload
|
||||
fileChanged = true
|
||||
totalStyleFixes += shapeTypeFixes
|
||||
}
|
||||
if fileChanged {
|
||||
changed = true
|
||||
totalStyleFixes += styleFixes
|
||||
}
|
||||
|
||||
if !fileChanged {
|
||||
raw, err := file.OpenRaw()
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
entry, err := writer.CreateRaw(&header)
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
if _, err := io.Copy(entry, raw); err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := writer.CreateHeader(&header)
|
||||
if err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
if _, err := entry.Write(sanitizedPayload); err != nil {
|
||||
_ = writer.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
if !changed {
|
||||
return payload, 0, false, nil
|
||||
}
|
||||
return buf.Bytes(), totalStyleFixes, true, nil
|
||||
}
|
||||
|
||||
func sanitizeVMLDrawingPayload(payload []byte) ([]byte, int, bool, error) {
|
||||
totalFixes := 0
|
||||
changed := false
|
||||
|
||||
rewritten := xlsxVMLStyleAttrRE.ReplaceAllFunc(payload, func(match []byte) []byte {
|
||||
sub := xlsxVMLStyleAttrRE.FindSubmatch(match)
|
||||
if len(sub) != 4 {
|
||||
return match
|
||||
}
|
||||
|
||||
normalized, styleFixes, fileChanged := sanitizeVMLStyleValue(string(sub[2]))
|
||||
if !fileChanged {
|
||||
return match
|
||||
}
|
||||
|
||||
totalFixes += styleFixes
|
||||
changed = true
|
||||
out := make([]byte, 0, len(sub[1])+len(normalized)+len(sub[3]))
|
||||
out = append(out, sub[1]...)
|
||||
out = append(out, normalized...)
|
||||
out = append(out, sub[3]...)
|
||||
return out
|
||||
})
|
||||
|
||||
if !changed {
|
||||
return payload, 0, false, nil
|
||||
}
|
||||
return rewritten, totalFixes, true, nil
|
||||
}
|
||||
|
||||
func dedupeVMLShapeIDs(payload []byte) ([]byte, int, bool) {
|
||||
if len(payload) == 0 {
|
||||
return payload, 0, false
|
||||
}
|
||||
|
||||
source := string(payload)
|
||||
tags := xlsxVMLShapeTagRE.FindAllStringIndex(source, -1)
|
||||
if len(tags) == 0 {
|
||||
return payload, 0, false
|
||||
}
|
||||
|
||||
maxID := 0
|
||||
for _, match := range xlsxVMLShapeIDAttrRE.FindAllStringSubmatch(source, -1) {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
if sub := xlsxVMLShapeIDNumRE.FindStringSubmatch(match[2]); len(sub) == 2 {
|
||||
if n, err := strconv.Atoi(sub[1]); err == nil && n > maxID {
|
||||
maxID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxID == 0 {
|
||||
return payload, 0, false
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(source) + len(tags)*8)
|
||||
last := 0
|
||||
nextID := maxID + 1
|
||||
fixCount := 0
|
||||
|
||||
for _, loc := range tags {
|
||||
tag := source[loc[0]:loc[1]]
|
||||
newTag, changed := dedupeVMLShapeIDTag(tag, seen, &nextID)
|
||||
if changed {
|
||||
fixCount++
|
||||
}
|
||||
buf.WriteString(source[last:loc[0]])
|
||||
buf.WriteString(newTag)
|
||||
last = loc[1]
|
||||
}
|
||||
buf.WriteString(source[last:])
|
||||
|
||||
if fixCount == 0 {
|
||||
return payload, 0, false
|
||||
}
|
||||
return []byte(buf.String()), fixCount, true
|
||||
}
|
||||
|
||||
func dedupeVMLShapeIDTag(tag string, seen map[string]struct{}, nextID *int) (string, bool) {
|
||||
match := xlsxVMLShapeIDAttrRE.FindStringSubmatchIndex(tag)
|
||||
if len(match) < 8 {
|
||||
return tag, false
|
||||
}
|
||||
currentID := tag[match[4]:match[5]]
|
||||
if _, ok := seen[currentID]; !ok {
|
||||
seen[currentID] = struct{}{}
|
||||
return tag, false
|
||||
}
|
||||
if nextID == nil {
|
||||
return tag, false
|
||||
}
|
||||
newID := "_x0000_s" + strconv.Itoa(*nextID)
|
||||
*nextID = *nextID + 1
|
||||
return tag[:match[4]] + newID + tag[match[5]:], true
|
||||
}
|
||||
|
||||
// ensureVMLNoteShapeType injects the standard note <v:shapetype> definition
|
||||
// (and its <o:shapelayout> preamble) when the drawing references
|
||||
// type="#_x0000_t202" but never defines it. Returns the payload unchanged when
|
||||
// a shapetype already exists, when no note shape references it, or when the
|
||||
// root <xml> element lacks the o:/v: namespaces the injected markup needs.
|
||||
func ensureVMLNoteShapeType(payload []byte) ([]byte, int, bool) {
|
||||
if len(payload) == 0 {
|
||||
return payload, 0, false
|
||||
}
|
||||
if xlsxVMLShapeTypeRE.Match(payload) {
|
||||
return payload, 0, false
|
||||
}
|
||||
if !xlsxVMLNoteTypeRefRE.Match(payload) {
|
||||
return payload, 0, false
|
||||
}
|
||||
|
||||
root := xlsxVMLRootTagRE.FindIndex(payload)
|
||||
if root == nil {
|
||||
return payload, 0, false
|
||||
}
|
||||
rootTag := bytes.ToLower(payload[root[0]:root[1]])
|
||||
if !bytes.Contains(rootTag, []byte("xmlns:o")) || !bytes.Contains(rootTag, []byte("xmlns:v")) {
|
||||
return payload, 0, false
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(payload)+len(xlsxVMLNoteShapeTypeHeader))
|
||||
out = append(out, payload[:root[1]]...)
|
||||
out = append(out, xlsxVMLNoteShapeTypeHeader...)
|
||||
out = append(out, payload[root[1]:]...)
|
||||
return out, 1, true
|
||||
}
|
||||
|
||||
func sanitizeVMLStyleValue(raw string) (string, int, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return raw, 0, false
|
||||
}
|
||||
|
||||
tokens := strings.Split(trimmed, ";")
|
||||
if len(tokens) == 0 {
|
||||
return raw, 0, false
|
||||
}
|
||||
|
||||
positionIndex := -1
|
||||
for i := range tokens {
|
||||
if isVMLAbsolutePositionToken(tokens[i]) {
|
||||
positionIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if positionIndex < 0 {
|
||||
return raw, 0, false
|
||||
}
|
||||
|
||||
leadingLengths := make([]string, 0, 2)
|
||||
nextIndex := positionIndex + 1
|
||||
for nextIndex < len(tokens) && len(leadingLengths) < 2 {
|
||||
token := strings.TrimSpace(tokens[nextIndex])
|
||||
if token == "" {
|
||||
nextIndex++
|
||||
continue
|
||||
}
|
||||
if !isVMLLengthToken(token) {
|
||||
break
|
||||
}
|
||||
leadingLengths = append(leadingLengths, token)
|
||||
nextIndex++
|
||||
}
|
||||
|
||||
if len(leadingLengths) == 0 {
|
||||
return raw, 0, false
|
||||
}
|
||||
|
||||
remaining := make([]string, 0, len(tokens)-nextIndex+3)
|
||||
for ; nextIndex < len(tokens); nextIndex++ {
|
||||
token := strings.TrimSpace(tokens[nextIndex])
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, token)
|
||||
}
|
||||
|
||||
styleParts := make([]string, 0, len(tokens)+2)
|
||||
for i := 0; i < positionIndex; i++ {
|
||||
token := strings.TrimSpace(tokens[i])
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
styleParts = append(styleParts, token)
|
||||
}
|
||||
styleParts = append(styleParts, "position:absolute")
|
||||
styleParts = append(styleParts, "margin-left:"+leadingLengths[0])
|
||||
if len(leadingLengths) >= 2 {
|
||||
styleParts = append(styleParts, "margin-top:"+leadingLengths[1])
|
||||
} else {
|
||||
styleParts = append(styleParts, "margin-top:0pt")
|
||||
}
|
||||
styleParts = append(styleParts, remaining...)
|
||||
|
||||
return strings.Join(styleParts, ";"), len(leadingLengths), true
|
||||
}
|
||||
|
||||
func isVMLDrawingEntry(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(name, "\\", "/")))
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(name, "xl/drawings/vmldrawing") && strings.HasSuffix(name, ".vml")
|
||||
}
|
||||
|
||||
func isVMLAbsolutePositionToken(token string) bool {
|
||||
token = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(token), " ", ""))
|
||||
return token == "position:absolute"
|
||||
}
|
||||
|
||||
func isVMLLengthToken(token string) bool {
|
||||
return xlsxVMLLengthTokenRE.MatchString(strings.ToLower(strings.TrimSpace(token)))
|
||||
}
|
||||
|
||||
func (h *FileManagerFileHandler) sanitizeStoredXLSXObject(ctx context.Context, row *filemanager.File, objectKey, nameHint, mimeHint, operation string) (bool, int64, error) {
|
||||
if h == nil || h.storage == nil {
|
||||
return false, 0, nil
|
||||
}
|
||||
storage, ok := any(h.storage).(xlsxSanitizerStorage)
|
||||
if !ok {
|
||||
return false, 0, errors.New("storage does not support office sanitization")
|
||||
}
|
||||
return sanitizeStoredXLSXObject(ctx, storage, h.svc, row, objectKey, nameHint, mimeHint, operation)
|
||||
}
|
||||
|
||||
func sanitizeStoredXLSXObject(
|
||||
ctx context.Context,
|
||||
storage xlsxSanitizerStorage,
|
||||
updater xlsxSanitizerFileUpdater,
|
||||
row *filemanager.File,
|
||||
objectKey,
|
||||
nameHint,
|
||||
mimeHint,
|
||||
operation string,
|
||||
) (bool, int64, error) {
|
||||
if storage == nil {
|
||||
return false, 0, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
objectKey = strings.TrimSpace(objectKey)
|
||||
if objectKey == "" && row != nil {
|
||||
objectKey = strings.TrimSpace(row.ObjectKey)
|
||||
}
|
||||
if objectKey == "" {
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(nameHint)
|
||||
if name == "" && row != nil {
|
||||
name = strings.TrimSpace(row.Name)
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(objectKey), "."))
|
||||
}
|
||||
if ext != "xlsx" {
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
body, meta, err := storage.GetObject(ctx, objectKey)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
payload, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
sanitizedPayload, styleFixes, changed, err := sanitizeXLSXPayload(payload)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if !changed {
|
||||
return false, int64(len(payload)), nil
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(firstNonEmpty(mimeHint, meta.ContentType, canonicalOfficeContentType(name)))
|
||||
if contentType == "" {
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
}
|
||||
|
||||
newMeta, err := storage.PutObject(ctx, filemanager.PutObjectInput{
|
||||
ObjectKey: objectKey,
|
||||
ContentType: contentType,
|
||||
SizeBytes: int64(len(sanitizedPayload)),
|
||||
Body: bytes.NewReader(sanitizedPayload),
|
||||
})
|
||||
if err != nil {
|
||||
return true, int64(len(sanitizedPayload)), err
|
||||
}
|
||||
|
||||
logArgs := []any{
|
||||
slog.String("operation", strings.TrimSpace(operation)),
|
||||
slog.String("object_key", objectKey),
|
||||
slog.String("mime_type", contentType),
|
||||
slog.Int("style_fix_count", styleFixes),
|
||||
slog.Int("before_bytes", len(payload)),
|
||||
slog.Int("after_bytes", len(sanitizedPayload)),
|
||||
}
|
||||
if row != nil {
|
||||
logArgs = append(logArgs, slog.String("file_id", idString(row.ID)))
|
||||
logArgs = append(logArgs, slog.String("file_name", strings.TrimSpace(row.Name)))
|
||||
}
|
||||
slog.WarnContext(ctx, "sanitized malformed xlsx VML drawing", logArgs...)
|
||||
|
||||
if row != nil {
|
||||
row.SizeBytes = int64(len(sanitizedPayload))
|
||||
row.MimeType = contentType
|
||||
if strings.TrimSpace(newMeta.Bucket) != "" {
|
||||
row.Bucket = newMeta.Bucket
|
||||
}
|
||||
if strings.TrimSpace(newMeta.ObjectKey) != "" {
|
||||
row.ObjectKey = newMeta.ObjectKey
|
||||
}
|
||||
if strings.TrimSpace(newMeta.ETag) != "" {
|
||||
row.ETag = newMeta.ETag
|
||||
}
|
||||
if strings.TrimSpace(newMeta.VersionID) != "" {
|
||||
row.ObjectVersion = newMeta.VersionID
|
||||
}
|
||||
if updater != nil {
|
||||
if err := updater.UpdateFile(ctx, row); err != nil {
|
||||
return true, int64(len(sanitizedPayload)), err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, int64(len(sanitizedPayload)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user