diff --git a/go.mod b/go.mod
index 224c07a..0910457 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,11 @@
module example.com/tapm/deployment-broker
-go 1.24
+go 1.24.0
-require modernc.org/sqlite v1.38.2
+require (
+ github.com/xuri/excelize/v2 v2.10.1
+ modernc.org/sqlite v1.38.2
+)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -10,8 +13,16 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/richardlehane/mscfb v1.0.6 // indirect
+ github.com/richardlehane/msoleps v1.0.6 // indirect
+ github.com/tiendc/go-deepcopy v1.7.2 // indirect
+ github.com/xuri/efp v0.0.1 // indirect
+ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
- golang.org/x/sys v0.34.0 // indirect
+ golang.org/x/net v0.50.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index aac187a..61fb4de 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -8,19 +10,45 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
+github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
+github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
-golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
-golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
-golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
+golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
-golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
diff --git a/internal/app/audit_export.go b/internal/app/audit_export.go
index 0a689e6..c3ce36f 100644
--- a/internal/app/audit_export.go
+++ b/internal/app/audit_export.go
@@ -1,16 +1,14 @@
package app
import (
- "archive/zip"
- "compress/flate"
"fmt"
- "hash/crc32"
"io"
"log"
"net/http"
"strings"
"time"
- "unicode/utf8"
+
+ "github.com/xuri/excelize/v2"
)
const maxAuditExportRows = 1_048_572
@@ -77,135 +75,67 @@ func writeAuditWorkbook(
displayTimeZone = time.UTC
}
- archive := zip.NewWriter(output)
- files := []struct {
- name string
- content func(io.Writer) error
- }{
- {"[Content_Types].xml", func(w io.Writer) error {
- return writeOnlyString(w, contentTypesXML)
- }},
- {"_rels/.rels", func(w io.Writer) error {
- return writeOnlyString(w, packageRelationshipsXML)
- }},
- {"docProps/app.xml", func(w io.Writer) error {
- return writeOnlyString(w, appPropertiesXML)
- }},
- {"docProps/core.xml", func(w io.Writer) error {
- return writeAuditCoreProperties(w, exportedAt)
- }},
- {"xl/workbook.xml", func(w io.Writer) error {
- return writeOnlyString(w, workbookXML)
- }},
- {"xl/_rels/workbook.xml.rels", func(w io.Writer) error {
- return writeOnlyString(w, workbookRelationshipsXML)
- }},
- {"xl/styles.xml", func(w io.Writer) error {
- return writeOnlyString(w, auditStylesXML)
- }},
- {"xl/worksheets/sheet1.xml", func(w io.Writer) error {
- return writeAuditWorksheet(w, records, filters, exportedAt, displayTimeZone)
- }},
- }
-
- for _, file := range files {
- if err := writeRawZipFile(archive, file.name, file.content); err != nil {
- _ = archive.Close()
- return err
+ workbook := excelize.NewFile()
+ defer func() {
+ if err := workbook.Close(); err != nil {
+ log.Printf("close audit Excel workbook: %v", err)
}
- }
- return archive.Close()
-}
+ }()
-func writeRawZipFile(
- archive *zip.Writer,
- name string,
- content func(io.Writer) error,
-) error {
- checksum := crc32.NewIEEE()
- var compressedSize byteCounter
- var uncompressedSize byteCounter
- sizeCompressor, err := flate.NewWriter(&compressedSize, flate.DefaultCompression)
- if err != nil {
+ const sheet = "Audit Log"
+ if err := workbook.SetSheetName("Sheet1", sheet); err != nil {
return err
}
- if err := content(io.MultiWriter(checksum, &uncompressedSize, sizeCompressor)); err != nil {
- _ = sizeCompressor.Close()
+ if err := workbook.SetDocProps(&excelize.DocProperties{
+ Title: "TAPM Audit Log",
+ Creator: "TAPM Broker",
+ LastModifiedBy: "TAPM Broker",
+ Created: exportedAt.UTC().Format(time.RFC3339),
+ Modified: exportedAt.UTC().Format(time.RFC3339),
+ }); err != nil {
return err
}
- if err := sizeCompressor.Close(); err != nil {
- return err
- }
- header := &zip.FileHeader{
- Name: name,
- Method: zip.Deflate,
- CRC32: checksum.Sum32(),
- CompressedSize64: uint64(compressedSize),
- UncompressedSize64: uint64(uncompressedSize),
- }
- entry, err := archive.CreateRaw(header)
- if err != nil {
- return err
- }
- compressor, err := flate.NewWriter(entry, flate.DefaultCompression)
- if err != nil {
- return err
- }
- if err := content(compressor); err != nil {
- _ = compressor.Close()
- return err
- }
- return compressor.Close()
-}
-func writeAuditWorksheet(
- w io.Writer,
- records []auditRecord,
- filters auditFilters,
- exportedAt time.Time,
- displayTimeZone *time.Location,
-) error {
- lastRow := len(records) + 4
- if lastRow < 4 {
- lastRow = 4
+ styles, err := newAuditWorkbookStyles(workbook)
+ if err != nil {
+ return err
}
- if _, err := fmt.Fprintf(
- w,
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``+
- ``,
- lastRow,
+ stream, err := workbook.NewStreamWriter(sheet)
+ if err != nil {
+ return err
+ }
+ if err := configureAuditWorksheet(stream); err != nil {
+ return err
+ }
+
+ if err := stream.SetRow(
+ "A1",
+ styledAuditRow(styles.title, "TAPM Audit Log"),
+ excelize.RowOpts{Height: 30},
); err != nil {
return err
}
-
- if err := writeMergedRow(w, 1, "TAPM Audit Log", 1, 30); err != nil {
+ if err := stream.MergeCell("A1", "H1"); err != nil {
return err
}
+
metadata := fmt.Sprintf(
"Exported %s · %s · %d event(s)",
exportedAt.In(displayTimeZone).Format("Jan 2, 2006 3:04 PM MST"),
auditFilterDescription(filters),
len(records),
)
- if err := writeMergedRow(w, 2, metadata, 2, 24); err != nil {
+ if err := stream.SetRow(
+ "A2",
+ styledAuditRow(styles.metadata, metadata),
+ excelize.RowOpts{Height: 24},
+ ); err != nil {
return err
}
- if _, err := writeString(w, `
`); err != nil {
+ if err := stream.MergeCell("A2", "H2"); err != nil {
+ return err
+ }
+ if err := stream.SetRow("A3", []interface{}{nil}, excelize.RowOpts{Height: 8}); err != nil {
return err
}
@@ -219,151 +149,163 @@ func writeAuditWorksheet(
"Source IP",
"Details",
}
- if _, err := writeString(w, ``); err != nil {
- return err
- }
+ headerRow := make([]interface{}, len(headers))
for index, header := range headers {
- if err := writeInlineStringCell(w, cellReference(index+1, 4), header, 3); err != nil {
- return err
- }
+ headerRow[index] = excelize.Cell{StyleID: styles.header, Value: header}
}
- if _, err := writeString(w, `
`); err != nil {
+ if err := stream.SetRow("A4", headerRow, excelize.RowOpts{Height: 24}); err != nil {
return err
}
for index, record := range records {
rowNumber := index + 5
- if _, err := fmt.Fprintf(w, ``, rowNumber); err != nil {
- return err
+ local := record.CreatedAt.In(displayTimeZone)
+ displayedTime := time.Date(
+ local.Year(),
+ local.Month(),
+ local.Day(),
+ local.Hour(),
+ local.Minute(),
+ local.Second(),
+ local.Nanosecond(),
+ time.UTC,
+ )
+ row := []interface{}{
+ excelize.Cell{StyleID: styles.timestamp, Value: displayedTime},
+ excelize.Cell{StyleID: styles.body, Value: record.EventType},
+ excelize.Cell{StyleID: styles.body, Value: record.CustomerLabel},
+ excelize.Cell{StyleID: styles.body, Value: record.User},
+ excelize.Cell{StyleID: styles.body, Value: record.Hostname},
+ excelize.Cell{StyleID: styles.body, Value: record.PackageSlug},
+ excelize.Cell{StyleID: styles.body, Value: record.SourceIP},
+ excelize.Cell{StyleID: styles.details, Value: record.Details},
}
- if err := writeDateCell(
- w,
- cellReference(1, rowNumber),
- record.CreatedAt,
- displayTimeZone,
- 5,
- ); err != nil {
- return err
- }
- values := []string{
- record.EventType,
- record.CustomerLabel,
- record.User,
- record.Hostname,
- record.PackageSlug,
- record.SourceIP,
- record.Details,
- }
- for column, value := range values {
- style := 4
- if column == len(values)-1 {
- style = 6
- }
- if err := writeInlineStringCell(
- w,
- cellReference(column+2, rowNumber),
- value,
- style,
- ); err != nil {
- return err
- }
- }
- if _, err := writeString(w, `
`); err != nil {
+ if err := stream.SetRow(fmt.Sprintf("A%d", rowNumber), row); err != nil {
return err
}
}
- if _, err := fmt.Fprintf(
- w,
- ``+
- ``+
- ``+
- ``+
- ``,
- lastRow,
- ); err != nil {
+ if len(records) > 0 {
+ lastRow := len(records) + 4
+ showStripes := false
+ if err := stream.AddTable(&excelize.Table{
+ Range: fmt.Sprintf("A4:H%d", lastRow),
+ Name: "AuditLog",
+ StyleName: "TableStyleMedium2",
+ ShowRowStripes: &showStripes,
+ ShowFirstColumn: false,
+ ShowLastColumn: false,
+ }); err != nil {
+ return err
+ }
+ }
+ if err := stream.Flush(); err != nil {
return err
}
- return nil
+ return workbook.Write(output)
}
-func writeMergedRow(w io.Writer, row int, value string, style int, height int) error {
- if _, err := fmt.Fprintf(w, ``, row, height); err != nil {
- return err
+type auditWorkbookStyles struct {
+ title int
+ metadata int
+ header int
+ body int
+ timestamp int
+ details int
+}
+
+func newAuditWorkbookStyles(workbook *excelize.File) (auditWorkbookStyles, error) {
+ var styles auditWorkbookStyles
+ var err error
+
+ styles.title, err = workbook.NewStyle(&excelize.Style{
+ Font: &excelize.Font{
+ Bold: true,
+ Family: "Aptos Display",
+ Size: 18,
+ Color: "FFFFFF",
+ },
+ Fill: excelize.Fill{Type: "pattern", Color: []string{"33404A"}, Pattern: 1},
+ Alignment: &excelize.Alignment{Vertical: "center"},
+ })
+ if err != nil {
+ return styles, err
}
- for column := 1; column <= 8; column++ {
- cellValue := ""
- if column == 1 {
- cellValue = value
- }
- if err := writeInlineStringCell(w, cellReference(column, row), cellValue, style); err != nil {
+ styles.metadata, err = workbook.NewStyle(&excelize.Style{
+ Font: &excelize.Font{Family: "Aptos", Size: 11, Color: "33404A"},
+ Fill: excelize.Fill{Type: "pattern", Color: []string{"F4F7ED"}, Pattern: 1},
+ Alignment: &excelize.Alignment{
+ Vertical: "center",
+ },
+ })
+ if err != nil {
+ return styles, err
+ }
+ styles.header, err = workbook.NewStyle(&excelize.Style{
+ Font: &excelize.Font{
+ Bold: true,
+ Family: "Aptos",
+ Size: 11,
+ Color: "FFFFFF",
+ },
+ Fill: excelize.Fill{Type: "pattern", Color: []string{"33404A"}, Pattern: 1},
+ Alignment: &excelize.Alignment{Vertical: "center"},
+ })
+ if err != nil {
+ return styles, err
+ }
+ bodyStyle := excelize.Style{
+ Font: &excelize.Font{Family: "Aptos", Size: 11, Color: "33404A"},
+ Border: []excelize.Border{{Type: "bottom", Color: "D9DED4", Style: 1}},
+ Alignment: &excelize.Alignment{Vertical: "top"},
+ }
+ styles.body, err = workbook.NewStyle(&bodyStyle)
+ if err != nil {
+ return styles, err
+ }
+ numberFormat := "yyyy-mm-dd hh:mm:ss"
+ timestampStyle := bodyStyle
+ timestampStyle.CustomNumFmt = &numberFormat
+ styles.timestamp, err = workbook.NewStyle(×tampStyle)
+ if err != nil {
+ return styles, err
+ }
+ detailsStyle := bodyStyle
+ detailsStyle.Alignment = &excelize.Alignment{Vertical: "top", WrapText: true}
+ styles.details, err = workbook.NewStyle(&detailsStyle)
+ return styles, err
+}
+
+func configureAuditWorksheet(stream *excelize.StreamWriter) error {
+ widths := []float64{22, 28, 30, 22, 24, 24, 18, 54}
+ for index, width := range widths {
+ if err := stream.SetColWidth(index+1, index+1, width); err != nil {
return err
}
}
- _, err := writeString(w, `
`)
- return err
+ return stream.SetPanes(&excelize.Panes{
+ Freeze: true,
+ YSplit: 4,
+ TopLeftCell: "A5",
+ ActivePane: "bottomLeft",
+ Selection: []excelize.Selection{{
+ SQRef: "A5",
+ ActiveCell: "A5",
+ Pane: "bottomLeft",
+ }},
+ })
}
-func writeInlineStringCell(w io.Writer, reference string, value string, style int) error {
- if _, err := fmt.Fprintf(w, ``); err != nil {
- return err
- }
- if err := writeEscapedXML(w, value); err != nil {
- return err
- }
- _, err := writeString(w, ``)
- return err
-}
-
-func writeDateCell(
- w io.Writer,
- reference string,
- value time.Time,
- displayTimeZone *time.Location,
- style int,
-) error {
- local := value.In(displayTimeZone)
- displayedTime := time.Date(
- local.Year(),
- local.Month(),
- local.Day(),
- local.Hour(),
- local.Minute(),
- local.Second(),
- local.Nanosecond(),
- time.UTC,
- )
- serial := float64(displayedTime.Unix())/86_400 + 25_569
- _, err := fmt.Fprintf(w, `%.10f`, reference, style, serial)
- return err
-}
-
-func writeAuditCoreProperties(w io.Writer, exportedAt time.Time) error {
- if _, err := writeString(
- w,
- ``+
- ``+
- `TAPM Audit LogTAPM Broker`+
- ``,
- ); err != nil {
- return err
- }
- if err := writeEscapedXML(w, exportedAt.UTC().Format(time.RFC3339)); err != nil {
- return err
- }
- _, err := writeString(w, ``)
- return err
+ return row
}
func auditFilterDescription(filters auditFilters) string {
@@ -394,127 +336,3 @@ func auditFilterDescription(filters auditFilters) string {
}
return strings.Join(parts, " · ")
}
-
-func cellReference(column int, row int) string {
- var letters [3]byte
- position := len(letters)
- for column > 0 {
- column--
- position--
- letters[position] = byte('A' + column%26)
- column /= 26
- }
- return string(letters[position:]) + fmt.Sprintf("%d", row)
-}
-
-func writeEscapedXML(w io.Writer, value string) error {
- value = strings.ToValidUTF8(value, "\uFFFD")
- for _, character := range value {
- if character == '\t' || character == '\n' || character == '\r' ||
- (character >= 0x20 && character <= 0xD7FF) ||
- (character >= 0xE000 && character <= 0xFFFD) ||
- (character >= 0x10000 && character <= utf8.MaxRune) {
- switch character {
- case '&':
- if _, err := writeString(w, "&"); err != nil {
- return err
- }
- case '<':
- if _, err := writeString(w, "<"); err != nil {
- return err
- }
- case '>':
- if _, err := writeString(w, ">"); err != nil {
- return err
- }
- case '"':
- if _, err := writeString(w, """); err != nil {
- return err
- }
- case '\'':
- if _, err := writeString(w, "'"); err != nil {
- return err
- }
- default:
- if _, err := fmt.Fprint(w, string(character)); err != nil {
- return err
- }
- }
- }
- }
- return nil
-}
-
-func writeString(w io.Writer, value string) (int, error) {
- return io.WriteString(w, value)
-}
-
-func writeOnlyString(w io.Writer, value string) error {
- _, err := io.WriteString(w, value)
- return err
-}
-
-const contentTypesXML = `
-
-
-
-
-
-
-
-
-`
-
-const packageRelationshipsXML = `
-
-
-
-
-`
-
-const appPropertiesXML = `
-
- TAPM Broker
-`
-
-const workbookXML = `
-
-
-`
-
-const workbookRelationshipsXML = `
-
-
-
-`
-
-const auditStylesXML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`
diff --git a/internal/app/audit_export_test.go b/internal/app/audit_export_test.go
index 2e7f9a4..40cb52e 100644
--- a/internal/app/audit_export_test.go
+++ b/internal/app/audit_export_test.go
@@ -1,15 +1,15 @@
package app
import (
- "archive/zip"
"bytes"
- "io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
+
+ "github.com/xuri/excelize/v2"
)
func TestWriteAuditWorkbookCreatesSafeExcelFile(t *testing.T) {
@@ -44,50 +44,38 @@ func TestWriteAuditWorkbookCreatesSafeExcelFile(t *testing.T) {
t.Fatal("workbook is not a ZIP-based Excel file")
}
- archive, err := zip.NewReader(bytes.NewReader(output.Bytes()), int64(output.Len()))
+ workbook, err := excelize.OpenReader(bytes.NewReader(output.Bytes()))
if err != nil {
t.Fatal(err)
}
- required := map[string]bool{
- "[Content_Types].xml": false,
- "xl/workbook.xml": false,
- "xl/styles.xml": false,
- "xl/worksheets/sheet1.xml": false,
- "xl/_rels/workbook.xml.rels": false,
- }
- var worksheet string
- for _, file := range archive.File {
- if _, ok := required[file.Name]; ok {
- required[file.Name] = true
+ defer func() {
+ if err := workbook.Close(); err != nil {
+ t.Error(err)
}
- if file.Name != "xl/worksheets/sheet1.xml" {
- continue
- }
- reader, err := file.Open()
- if err != nil {
- t.Fatal(err)
- }
- content, err := io.ReadAll(reader)
- _ = reader.Close()
- if err != nil {
- t.Fatal(err)
- }
- worksheet = string(content)
+ }()
+ if sheets := workbook.GetSheetList(); len(sheets) != 1 || sheets[0] != "Audit Log" {
+ t.Fatalf("sheets = %#v, want Audit Log", sheets)
}
- for file, found := range required {
- if !found {
- t.Errorf("workbook is missing %s", file)
- }
+ customer, err := workbook.GetCellValue("Audit Log", "C5")
+ if err != nil {
+ t.Fatal(err)
}
- if !strings.Contains(worksheet, "Acme & Sons") {
- t.Fatal("worksheet text was not XML escaped")
+ if customer != "Acme & Sons" {
+ t.Fatalf("customer = %q, want Acme & Sons", customer)
}
- if strings.Contains(worksheet, "") {
- t.Fatal("untrusted audit text must not be written as a formula")
+ hostname, err := workbook.GetCellValue("Audit Log", "E5")
+ if err != nil {
+ t.Fatal(err)
}
- if !strings.Contains(worksheet, `t="inlineStr"`) ||
- !strings.Contains(worksheet, `=HYPERLINK`) {
- t.Fatal("formula-looking audit text was not preserved as a text cell")
+ if hostname != `=HYPERLINK("https://invalid.example")` {
+ t.Fatalf("hostname = %q, formula-looking text was not preserved", hostname)
+ }
+ formula, err := workbook.GetCellFormula("Audit Log", "E5")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if formula != "" {
+ t.Fatalf("untrusted audit text was written as formula %q", formula)
}
if samplePath := os.Getenv("TAPM_AUDIT_SAMPLE_PATH"); samplePath != "" {
@@ -128,31 +116,26 @@ func TestAuditExportReturnsAllRowsAndRecordsDownload(t *testing.T) {
t.Fatalf("content disposition = %q", disposition)
}
- archive, err := zip.NewReader(
- bytes.NewReader(response.Body.Bytes()),
- int64(response.Body.Len()),
- )
+ workbook, err := excelize.OpenReader(bytes.NewReader(response.Body.Bytes()))
if err != nil {
t.Fatal(err)
}
- var worksheet string
- for _, file := range archive.File {
- if file.Name != "xl/worksheets/sheet1.xml" {
- continue
+ defer func() {
+ if err := workbook.Close(); err != nil {
+ t.Error(err)
}
- reader, err := file.Open()
- if err != nil {
- t.Fatal(err)
- }
- content, err := io.ReadAll(reader)
- _ = reader.Close()
- if err != nil {
- t.Fatal(err)
- }
- worksheet = string(content)
+ }()
+ rows, err := workbook.GetRows("Audit Log")
+ if err != nil {
+ t.Fatal(err)
}
- if !strings.Contains(worksheet, "older_event") ||
- !strings.Contains(worksheet, "newer_event") {
+ var exportedEvents []string
+ for _, row := range rows {
+ if len(row) > 1 && (row[1] == "older_event" || row[1] == "newer_event") {
+ exportedEvents = append(exportedEvents, row[1])
+ }
+ }
+ if len(exportedEvents) != 2 {
t.Fatal("complete export did not contain all retained audit rows")
}