From 54a3dff1c214b9d4e5cac54b34d8bbddeb80aeaa Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Sat, 14 Feb 2026 09:03:41 +0100 Subject: [PATCH 01/14] Add TNEF handling and email loading improvements - Implement TNEF extraction and recursive parsing in new `tnef_reader.go` and associated tests. - Create tests for TNEF extraction scenarios in `tnef_diag_test.go`, `tnef_diag7_test.go`, and `tnef_diag8_test.go`. --- .claude/settings.local.json | 13 + app_mail.go | 41 ++ backend/utils/mail/eml_reader.go | 6 + backend/utils/mail/format_detector.go | 47 +++ backend/utils/mail/tnef_diag2_test.go | 58 +++ backend/utils/mail/tnef_diag3_test.go | 67 +++ backend/utils/mail/tnef_diag4_test.go | 78 ++++ backend/utils/mail/tnef_diag5_test.go | 241 +++++++++++ backend/utils/mail/tnef_diag6_test.go | 209 +++++++++ backend/utils/mail/tnef_diag7_test.go | 273 ++++++++++++ backend/utils/mail/tnef_diag8_test.go | 97 +++++ backend/utils/mail/tnef_diag_test.go | 79 ++++ backend/utils/mail/tnef_reader.go | 444 ++++++++++++++++++++ backend/utils/mail/tnef_reader_test.go | 59 +++ frontend/bun.lock | 19 +- frontend/package.json.md5 | 2 +- frontend/src/lib/utils/mail/email-loader.ts | 63 ++- frontend/src/lib/utils/mail/index.ts | 1 + frontend/src/lib/wailsjs/go/main/App.d.ts | 44 +- frontend/src/lib/wailsjs/go/main/App.js | 84 ++++ frontend/src/lib/wailsjs/go/models.ts | 110 +++++ go.mod | 3 + go.sum | 9 + 23 files changed, 2029 insertions(+), 18 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 backend/utils/mail/format_detector.go create mode 100644 backend/utils/mail/tnef_diag2_test.go create mode 100644 backend/utils/mail/tnef_diag3_test.go create mode 100644 backend/utils/mail/tnef_diag4_test.go create mode 100644 backend/utils/mail/tnef_diag5_test.go create mode 100644 backend/utils/mail/tnef_diag6_test.go create mode 100644 backend/utils/mail/tnef_diag7_test.go create mode 100644 backend/utils/mail/tnef_diag8_test.go create mode 100644 backend/utils/mail/tnef_diag_test.go create mode 100644 backend/utils/mail/tnef_reader.go create mode 100644 backend/utils/mail/tnef_reader_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..aacf7b5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch(domain:github.com)", + "WebFetch(domain:www.gnu.org)", + "Bash(go run:*)", + "Bash(go build:*)", + "Bash(go doc:*)", + "Bash(go test:*)" + ] + } +} diff --git a/app_mail.go b/app_mail.go index 003e2ff..3358490 100644 --- a/app_mail.go +++ b/app_mail.go @@ -73,6 +73,47 @@ func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) { return internal.ReadMsgFile(filePath) } +// DetectEmailFormat inspects the file's binary content to determine its format, +// regardless of the file extension. Returns "eml", "msg", or "unknown". +// +// Parameters: +// - filePath: Absolute path to the file to inspect +// +// Returns: +// - string: Detected format ("eml", "msg", or "unknown") +// - error: Any file I/O errors +func (a *App) DetectEmailFormat(filePath string) (string, error) { + format, err := internal.DetectEmailFormat(filePath) + return string(format), err +} + +// ReadAuto automatically detects the email file format from its binary content +// and delegates to the appropriate reader (ReadEML/ReadPEC for EML, ReadMSG for MSG). +// +// Parameters: +// - filePath: Absolute path to the email file +// +// Returns: +// - *internal.EmailData: Parsed email data +// - error: Any parsing or detection errors +func (a *App) ReadAuto(filePath string) (*internal.EmailData, error) { + format, err := internal.DetectEmailFormat(filePath) + if err != nil { + return nil, err + } + + switch format { + case internal.FormatMSG: + return internal.ReadMsgFile(filePath) + default: // FormatEML or FormatUnknown – try PEC first, fall back to plain EML + data, err := internal.ReadPecInnerEml(filePath) + if err != nil { + return internal.ReadEmlFile(filePath) + } + return data, nil + } +} + // ShowOpenFileDialog displays the system file picker dialog filtered for email files. // This allows users to browse and select .eml or .msg files to open. // diff --git a/backend/utils/mail/eml_reader.go b/backend/utils/mail/eml_reader.go index 8cf0ebe..49c71e0 100644 --- a/backend/utils/mail/eml_reader.go +++ b/backend/utils/mail/eml_reader.go @@ -146,6 +146,9 @@ func ReadEmlFile(filePath string) (*EmailData, error) { }) } + // Expand any TNEF (winmail.dat) attachments into their contained files. + attachments = expandTNEFAttachments(attachments) + isPec := hasDatiCert && hasSmime // Format From @@ -267,6 +270,9 @@ func ReadPecInnerEml(filePath string) (*EmailData, error) { }) } + // Expand any TNEF (winmail.dat) attachments into their contained files. + attachments = expandTNEFAttachments(attachments) + isPec := hasDatiCert && hasSmime // Format From diff --git a/backend/utils/mail/format_detector.go b/backend/utils/mail/format_detector.go new file mode 100644 index 0000000..da07e07 --- /dev/null +++ b/backend/utils/mail/format_detector.go @@ -0,0 +1,47 @@ +package internal + +import ( + "bytes" + "os" +) + +// EmailFormat represents the detected format of an email file. +type EmailFormat string + +const ( + FormatEML EmailFormat = "eml" + FormatMSG EmailFormat = "msg" + FormatUnknown EmailFormat = "unknown" +) + +// msgMagic is the OLE2/CFB compound file header signature used by .msg files. +var msgMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1} + +// DetectEmailFormat identifies the email file format by inspecting the file's +// binary magic bytes, regardless of the file extension. +// +// Supported formats: +// - "msg": Microsoft Outlook MSG (OLE2/CFB compound file) +// - "eml": Standard MIME email (RFC 5322) +// - "unknown": Could not determine format +func DetectEmailFormat(filePath string) (EmailFormat, error) { + f, err := os.Open(filePath) + if err != nil { + return FormatUnknown, err + } + defer f.Close() + + buf := make([]byte, 8) + n, err := f.Read(buf) + if err != nil || n < 1 { + return FormatUnknown, nil + } + + // MSG files start with the OLE2 Compound File Binary magic bytes. + if n >= 8 && bytes.Equal(buf[:8], msgMagic) { + return FormatMSG, nil + } + + // EML files are plain-text MIME messages; assume EML for anything else. + return FormatEML, nil +} diff --git a/backend/utils/mail/tnef_diag2_test.go b/backend/utils/mail/tnef_diag2_test.go new file mode 100644 index 0000000..a32ba7f --- /dev/null +++ b/backend/utils/mail/tnef_diag2_test.go @@ -0,0 +1,58 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFAttributes(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + decoded, _ := tnef.Decode(data) + fmt.Printf("MAPI Attributes (%d):\n", len(decoded.Attributes)) + for _, attr := range decoded.Attributes { + dataPreview := fmt.Sprintf("%d bytes", len(attr.Data)) + if len(attr.Data) < 200 { + dataPreview = fmt.Sprintf("%q", attr.Data) + } + fmt.Printf(" Name=0x%04X Data=%s\n", attr.Name, dataPreview) + } + + // Check Body/BodyHTML from TNEF data struct fields + fmt.Printf("\nBody len: %d\n", len(decoded.Body)) + fmt.Printf("BodyHTML len: %d\n", len(decoded.BodyHTML)) + + // Check attachment details + for i, ta := range decoded.Attachments { + fmt.Printf("Attachment[%d]: title=%q dataLen=%d\n", i, ta.Title, len(ta.Data)) + } + } +} diff --git a/backend/utils/mail/tnef_diag3_test.go b/backend/utils/mail/tnef_diag3_test.go new file mode 100644 index 0000000..4a3399d --- /dev/null +++ b/backend/utils/mail/tnef_diag3_test.go @@ -0,0 +1,67 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFAllSizes(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + decoded, _ := tnef.Decode(data) + + totalAttrSize := 0 + for _, attr := range decoded.Attributes { + totalAttrSize += len(attr.Data) + fmt.Printf(" Attr 0x%04X: %d bytes\n", attr.Name, len(attr.Data)) + } + + totalAttSize := 0 + for _, ta := range decoded.Attachments { + totalAttSize += len(ta.Data) + } + + fmt.Printf("\nTotal TNEF data: %d bytes\n", len(data)) + fmt.Printf("Total attribute data: %d bytes\n", totalAttrSize) + fmt.Printf("Total attachment data: %d bytes\n", totalAttSize) + fmt.Printf("Accounted: %d bytes\n", totalAttrSize+totalAttSize) + fmt.Printf("Missing: %d bytes\n", len(data)-totalAttrSize-totalAttSize) + + // Try raw decode to check for nested message/attachment objects + fmt.Printf("\nBody: %d, BodyHTML: %d\n", len(decoded.Body), len(decoded.BodyHTML)) + + // Check attachment[0] content + if len(decoded.Attachments) > 0 { + a0 := decoded.Attachments[0] + fmt.Printf("\nAttachment[0] Title=%q Data (hex): %x\n", a0.Title, a0.Data) + } + } +} diff --git a/backend/utils/mail/tnef_diag4_test.go b/backend/utils/mail/tnef_diag4_test.go new file mode 100644 index 0000000..5c234e5 --- /dev/null +++ b/backend/utils/mail/tnef_diag4_test.go @@ -0,0 +1,78 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" +) + +func TestTNEFRawScan(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + fmt.Printf("TNEF raw size: %d bytes\n", len(data)) + + // Verify signature + if len(data) < 6 { + t.Fatal("too short") + } + sig := binary.LittleEndian.Uint32(data[0:4]) + key := binary.LittleEndian.Uint16(data[4:6]) + fmt.Printf("Signature: 0x%08X Key: 0x%04X\n", sig, key) + + offset := 6 + attrNum := 0 + for offset < len(data) { + if offset+9 > len(data) { + fmt.Printf(" Truncated at offset %d\n", offset) + break + } + + level := data[offset] + attrID := binary.LittleEndian.Uint32(data[offset+1 : offset+5]) + attrLen := binary.LittleEndian.Uint32(data[offset+5 : offset+9]) + + levelStr := "MSG" + if level == 0x02 { + levelStr = "ATT" + } + + fmt.Printf(" [%03d] offset=%-8d level=%s id=0x%08X len=%d\n", + attrNum, offset, levelStr, attrID, attrLen) + + // Move past: level(1) + id(4) + len(4) + data(attrLen) + checksum(2) + offset += 1 + 4 + 4 + int(attrLen) + 2 + + attrNum++ + if attrNum > 200 { + fmt.Println(" ... stopping at 200 attributes") + break + } + } + } +} diff --git a/backend/utils/mail/tnef_diag5_test.go b/backend/utils/mail/tnef_diag5_test.go new file mode 100644 index 0000000..c4e0189 --- /dev/null +++ b/backend/utils/mail/tnef_diag5_test.go @@ -0,0 +1,241 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" +) + +func TestTNEFMapiProps(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + // Navigate to the first attachment's attAttachment (0x9005) block + // From the raw scan: [011] offset=12082 + header(9bytes) = 12091 for data + // Actually let's re-scan to find it properly + offset := 6 + for offset < len(rawData) { + if offset+9 > len(rawData) { + break + } + level := rawData[offset] + attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9])) + dataStart := offset + 9 + + // attAttachment = 0x00069005, we want the FIRST one (for attachment group 1) + if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 { + fmt.Printf("Found attAttachment at offset %d, len=%d\n", offset, attrLen) + parseMapiProps(rawData[dataStart:dataStart+attrLen], t) + break + } + + offset += 9 + attrLen + 2 + } + } +} + +func parseMapiProps(data []byte, t *testing.T) { + if len(data) < 4 { + t.Fatal("too short for MAPI props") + } + + count := binary.LittleEndian.Uint32(data[0:4]) + fmt.Printf("MAPI property count: %d\n", count) + + offset := 4 + for i := 0; i < int(count) && offset+4 <= len(data); i++ { + propTag := binary.LittleEndian.Uint32(data[offset : offset+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + offset += 4 + + // Handle named properties (ID >= 0x8000) + if propID >= 0x8000 { + // Skip GUID (16 bytes) + kind (4 bytes) + if offset+20 > len(data) { + break + } + kind := binary.LittleEndian.Uint32(data[offset+16 : offset+20]) + offset += 20 + if kind == 0 { // MNID_ID + offset += 4 // skip NamedID + } else { // MNID_STRING + if offset+4 > len(data) { + break + } + nameLen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + nameLen + // Pad to 4-byte boundary + if nameLen%4 != 0 { + offset += 4 - nameLen%4 + } + } + } + + var valueSize int + switch propType { + case 0x0002: // PT_SHORT + valueSize = 4 // padded to 4 + case 0x0003: // PT_LONG + valueSize = 4 + case 0x000B: // PT_BOOLEAN + valueSize = 4 + case 0x0040: // PT_SYSTIME + valueSize = 8 + case 0x001E: // PT_STRING8 + if offset+4 > len(data) { + return + } + // count=1, then length, then data padded + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + slen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + strData := "" + if offset+slen <= len(data) && slen < 200 { + strData = string(data[offset : offset+slen]) + } + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X STRING8 len=%d val=%q\n", i, propID, propType, slen, strData) + offset += slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + continue + case 0x001F: // PT_UNICODE + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + slen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X UNICODE len=%d\n", i, propID, propType, slen) + offset += slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + continue + case 0x0102: // PT_BINARY + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + blen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X BINARY len=%d\n", i, propID, propType, blen) + offset += blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + continue + case 0x000D: // PT_OBJECT + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + olen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X OBJECT len=%d\n", i, propID, propType, olen) + // Peek at first 16 bytes (GUID) + if offset+16 <= len(data) { + fmt.Printf(" GUID: %x\n", data[offset:offset+16]) + } + offset += olen + if olen%4 != 0 { + offset += 4 - olen%4 + } + } + continue + case 0x1003: // PT_MV_LONG + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_LONG count=%d\n", i, propID, propType, cnt) + offset += cnt * 4 + continue + case 0x1102: // PT_MV_BINARY + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + totalSize := 0 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + blen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + totalSize += blen + offset += blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_BINARY count=%d totalSize=%d\n", i, propID, propType, cnt, totalSize) + continue + default: + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X (unknown type)\n", i, propID, propType) + return + } + + if valueSize > 0 { + if propType == 0x0003 && offset+4 <= len(data) { + val := binary.LittleEndian.Uint32(data[offset : offset+4]) + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X LONG val=%d (0x%X)\n", i, propID, propType, val, val) + } else { + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X size=%d\n", i, propID, propType, valueSize) + } + offset += valueSize + } + } +} diff --git a/backend/utils/mail/tnef_diag6_test.go b/backend/utils/mail/tnef_diag6_test.go new file mode 100644 index 0000000..575e775 --- /dev/null +++ b/backend/utils/mail/tnef_diag6_test.go @@ -0,0 +1,209 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFNestedMessage(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + // Navigate to attAttachment (0x9005) for first attachment + offset := 6 + for offset < len(rawData) { + if offset+9 > len(rawData) { + break + } + level := rawData[offset] + attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9])) + dataStart := offset + 9 + + if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 { + mapiData := rawData[dataStart : dataStart+attrLen] + + // Parse MAPI props to find PR_ATTACH_DATA_OBJ (0x3701) + embeddedData := extractPRAttachDataObj(mapiData) + if embeddedData == nil { + t.Fatal("could not find PR_ATTACH_DATA_OBJ") + } + + fmt.Printf("PR_ATTACH_DATA_OBJ total: %d bytes\n", len(embeddedData)) + fmt.Printf("First 32 bytes after GUID: %x\n", embeddedData[16:min2(48, len(embeddedData))]) + + // Check if after the 16-byte GUID there's a TNEF signature + afterGuid := embeddedData[16:] + if len(afterGuid) >= 4 { + sig := binary.LittleEndian.Uint32(afterGuid[0:4]) + fmt.Printf("Signature after GUID: 0x%08X (TNEF=0x223E9F78)\n", sig) + + if sig == 0x223E9F78 { + fmt.Println("It's a nested TNEF stream!") + decoded, err := tnef.Decode(afterGuid) + if err != nil { + fmt.Printf("Nested TNEF decode error: %v\n", err) + } else { + fmt.Printf("Nested Body: %d bytes\n", len(decoded.Body)) + fmt.Printf("Nested BodyHTML: %d bytes\n", len(decoded.BodyHTML)) + fmt.Printf("Nested Attachments: %d\n", len(decoded.Attachments)) + for i, na := range decoded.Attachments { + fmt.Printf(" [%d] %q (%d bytes)\n", i, na.Title, len(na.Data)) + } + fmt.Printf("Nested Attributes: %d\n", len(decoded.Attributes)) + } + } else { + // Try as raw MAPI attributes (no TNEF wrapper) + fmt.Printf("Not a TNEF stream. First byte: 0x%02X\n", afterGuid[0]) + // Check if it's a count of MAPI properties + if len(afterGuid) >= 4 { + propCount := binary.LittleEndian.Uint32(afterGuid[0:4]) + fmt.Printf("First uint32 (possible prop count): %d\n", propCount) + } + } + } + break + } + + offset += 9 + attrLen + 2 + } + } +} + +func extractPRAttachDataObj(mapiData []byte) []byte { + if len(mapiData) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(mapiData[0:4])) + offset := 4 + + for i := 0; i < count && offset+4 <= len(mapiData); i++ { + propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + offset += 4 + + // Handle named props + if propID >= 0x8000 { + if offset+20 > len(mapiData) { + return nil + } + kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20]) + offset += 20 + if kind == 0 { + offset += 4 + } else { + if offset+4 > len(mapiData) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + nameLen + if nameLen%4 != 0 { + offset += 4 - nameLen%4 + } + } + } + + switch propType { + case 0x0002: // PT_SHORT + offset += 4 + case 0x0003: // PT_LONG + offset += 4 + case 0x000B: // PT_BOOLEAN + offset += 4 + case 0x0040: // PT_SYSTIME + offset += 8 + case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + case 0x0102: // PT_BINARY + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + case 0x000D: // PT_OBJECT + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + if propID == 0x3701 { + // This is PR_ATTACH_DATA_OBJ! + return mapiData[offset : offset+olen] + } + offset += olen + if olen%4 != 0 { + offset += 4 - olen%4 + } + } + default: + return nil + } + } + return nil +} + +func min2(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/utils/mail/tnef_diag7_test.go b/backend/utils/mail/tnef_diag7_test.go new file mode 100644 index 0000000..cad65c5 --- /dev/null +++ b/backend/utils/mail/tnef_diag7_test.go @@ -0,0 +1,273 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFRecursiveExtract(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + fmt.Println("=== Level 0 (top TNEF) ===") + atts, body := recursiveExtract(rawData, 0) + fmt.Printf("\nTotal extracted attachments: %d\n", len(atts)) + for i, a := range atts { + fmt.Printf(" [%d] %q (%d bytes)\n", i, a.Title, len(a.Data)) + } + fmt.Printf("Body HTML len: %d\n", len(body)) + if len(body) > 0 && len(body) < 500 { + fmt.Printf("Body: %s\n", body) + } + } +} + +func recursiveExtract(tnefData []byte, depth int) ([]*tnef.Attachment, string) { + prefix := strings.Repeat(" ", depth) + + decoded, err := tnef.Decode(tnefData) + if err != nil { + fmt.Printf("%sDecode error: %v\n", prefix, err) + return nil, "" + } + + // Collect body + bodyHTML := string(decoded.BodyHTML) + bodyText := string(decoded.Body) + + // Check for RTF body in attributes + for _, attr := range decoded.Attributes { + if attr.Name == 0x1009 { + fmt.Printf("%sFound PR_RTF_COMPRESSED: %d bytes\n", prefix, len(attr.Data)) + } + if attr.Name == 0x1000 { + fmt.Printf("%sFound PR_BODY: %d bytes\n", prefix, len(attr.Data)) + if bodyText == "" { + bodyText = string(attr.Data) + } + } + if attr.Name == 0x1013 || attr.Name == 0x1035 { + fmt.Printf("%sFound PR_BODY_HTML/PR_HTML: %d bytes\n", prefix, len(attr.Data)) + if bodyHTML == "" { + bodyHTML = string(attr.Data) + } + } + } + + fmt.Printf("%sAttachments: %d, Body: %d, BodyHTML: %d\n", + prefix, len(decoded.Attachments), len(bodyText), len(bodyHTML)) + + var allAttachments []*tnef.Attachment + + // Collect real attachments (skip placeholders) + for _, a := range decoded.Attachments { + if a.Title == "Untitled Attachment" && len(a.Data) < 200 { + fmt.Printf("%sSkipping placeholder: %q (%d bytes)\n", prefix, a.Title, len(a.Data)) + continue + } + allAttachments = append(allAttachments, a) + } + + // Now scan for embedded messages in raw TNEF + embeddedStreams := findEmbeddedTNEFStreams(tnefData) + for i, stream := range embeddedStreams { + fmt.Printf("%s--- Recursing into embedded message %d (%d bytes) ---\n", prefix, i, len(stream)) + subAtts, subBody := recursiveExtract(stream, depth+1) + allAttachments = append(allAttachments, subAtts...) + if bodyHTML == "" && subBody != "" { + bodyHTML = subBody + } + } + + if bodyHTML != "" { + return allAttachments, bodyHTML + } + return allAttachments, bodyText +} + +func findEmbeddedTNEFStreams(tnefData []byte) [][]byte { + var streams [][]byte + + // Navigate through TNEF attributes + offset := 6 + for offset+9 < len(tnefData) { + level := tnefData[offset] + attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9])) + dataStart := offset + 9 + + if dataStart+attrLen > len(tnefData) { + break + } + + // attAttachment (0x9005) at attachment level + if level == 0x02 && attrID == 0x00069005 && attrLen > 100 { + mapiData := tnefData[dataStart : dataStart+attrLen] + embedded := extractPRAttachDataObj2(mapiData) + if embedded != nil && len(embedded) > 22 { + // Skip 16-byte GUID, check for TNEF signature + afterGuid := embedded[16:] + if len(afterGuid) >= 4 { + sig := binary.LittleEndian.Uint32(afterGuid[0:4]) + if sig == 0x223E9F78 { + streams = append(streams, afterGuid) + } + } + } + } + + offset += 9 + attrLen + 2 + } + return streams +} + +func extractPRAttachDataObj2(mapiData []byte) []byte { + if len(mapiData) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(mapiData[0:4])) + offset := 4 + + for i := 0; i < count && offset+4 <= len(mapiData); i++ { + propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + offset += 4 + + if propID >= 0x8000 { + if offset+20 > len(mapiData) { + return nil + } + kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20]) + offset += 20 + if kind == 0 { + offset += 4 + } else { + if offset+4 > len(mapiData) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + nameLen + if nameLen%4 != 0 { + offset += 4 - nameLen%4 + } + } + } + + switch propType { + case 0x0002: + offset += 4 + case 0x0003: + offset += 4 + case 0x000B: + offset += 4 + case 0x0040: + offset += 8 + case 0x001E, 0x001F: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + case 0x0102: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + case 0x000D: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + if propID == 0x3701 { + return mapiData[offset : offset+olen] + } + offset += olen + if olen%4 != 0 { + offset += 4 - olen%4 + } + } + case 0x1003: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + cnt*4 + case 0x1102: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + default: + return nil + } + } + return nil +} diff --git a/backend/utils/mail/tnef_diag8_test.go b/backend/utils/mail/tnef_diag8_test.go new file mode 100644 index 0000000..4b5b120 --- /dev/null +++ b/backend/utils/mail/tnef_diag8_test.go @@ -0,0 +1,97 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFDeepAttachment(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + // Dig to level 2: top → embedded[0] → embedded[0] + streams0 := findEmbeddedTNEFStreams(rawData) + if len(streams0) == 0 { + t.Fatal("no embedded streams at level 0") + } + streams1 := findEmbeddedTNEFStreams(streams0[0]) + if len(streams1) == 0 { + t.Fatal("no embedded streams at level 1") + } + + // Decode level 2 + decoded2, err := tnef.Decode(streams1[0]) + if err != nil { + t.Fatalf("level 2 decode: %v", err) + } + + fmt.Printf("Level 2 attachments: %d\n", len(decoded2.Attachments)) + for i, a := range decoded2.Attachments { + fmt.Printf(" [%d] title=%q size=%d\n", i, a.Title, len(a.Data)) + if len(a.Data) > 20 { + fmt.Printf(" first 20 bytes: %x\n", a.Data[:20]) + // Check for EML, MSG, TNEF signatures + if len(a.Data) >= 4 { + sig := binary.LittleEndian.Uint32(a.Data[0:4]) + if sig == 0x223E9F78 { + fmt.Println(" -> TNEF stream!") + } + } + if len(a.Data) >= 8 && bytes.Equal(a.Data[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) { + fmt.Println(" -> MSG (OLE2) file!") + } + // Check if text/EML + if a.Data[0] < 128 && a.Data[0] >= 32 { + preview := string(a.Data[:min2(200, len(a.Data))]) + if strings.Contains(preview, "From:") || strings.Contains(preview, "Content-Type") || strings.Contains(preview, "MIME") || strings.Contains(preview, "Received:") { + fmt.Printf(" -> Looks like an EML file! First 200 chars: %s\n", preview) + } else { + fmt.Printf(" -> Text data: %.200s\n", preview) + } + } + } + } + + // Also check level 2's attAttachment for embedded msgs + streams2 := findEmbeddedTNEFStreams(streams1[0]) + fmt.Printf("\nLevel 2 embedded TNEF streams: %d\n", len(streams2)) + + // Check all MAPI attributes at level 2 + fmt.Println("\nLevel 2 MAPI attributes:") + for _, attr := range decoded2.Attributes { + fmt.Printf(" 0x%04X: %d bytes\n", attr.Name, len(attr.Data)) + // PR_BODY + if attr.Name == 0x1000 && len(attr.Data) < 500 { + fmt.Printf(" PR_BODY: %s\n", string(attr.Data)) + } + } + } +} diff --git a/backend/utils/mail/tnef_diag_test.go b/backend/utils/mail/tnef_diag_test.go new file mode 100644 index 0000000..186de64 --- /dev/null +++ b/backend/utils/mail/tnef_diag_test.go @@ -0,0 +1,79 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFDiag(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + // Parse the PEC outer envelope + outerEmail, err := Parse(f) + if err != nil { + t.Fatalf("parse outer: %v", err) + } + + // Find postacert.eml + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + if innerData == nil { + t.Fatal("no postacert.eml found") + } + + // Parse inner email + innerEmail, err := Parse(bytes.NewReader(innerData)) + if err != nil { + t.Fatalf("parse inner: %v", err) + } + + fmt.Printf("Inner attachments: %d\n", len(innerEmail.Attachments)) + for i, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + fmt.Printf(" [%d] filename=%q contentType=%q size=%d\n", i, att.Filename, att.ContentType, len(data)) + + if strings.ToLower(att.Filename) == "winmail.dat" || + strings.Contains(strings.ToLower(att.ContentType), "ms-tnef") { + + fmt.Printf(" Found TNEF! First 20 bytes: %x\n", data[:min(20, len(data))]) + fmt.Printf(" isTNEFData: %v\n", isTNEFData(data)) + + decoded, err := tnef.Decode(data) + if err != nil { + fmt.Printf(" TNEF decode error: %v\n", err) + continue + } + fmt.Printf(" TNEF Body len: %d\n", len(decoded.Body)) + fmt.Printf(" TNEF BodyHTML len: %d\n", len(decoded.BodyHTML)) + fmt.Printf(" TNEF Attachments: %d\n", len(decoded.Attachments)) + for j, ta := range decoded.Attachments { + fmt.Printf(" [%d] title=%q size=%d\n", j, ta.Title, len(ta.Data)) + } + fmt.Printf(" TNEF Attributes: %d\n", len(decoded.Attributes)) + } + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/utils/mail/tnef_reader.go b/backend/utils/mail/tnef_reader.go new file mode 100644 index 0000000..bea8feb --- /dev/null +++ b/backend/utils/mail/tnef_reader.go @@ -0,0 +1,444 @@ +package internal + +import ( + "encoding/binary" + "mime" + "path/filepath" + "strings" + + "github.com/teamwork/tnef" +) + +// tnefMagic is the TNEF file signature (little-endian 0x223E9F78). +var tnefMagic = []byte{0x78, 0x9F, 0x3E, 0x22} + +const maxTNEFDepth = 10 + +// isTNEFData returns true if the given byte slice starts with the TNEF magic number. +func isTNEFData(data []byte) bool { + return len(data) >= 4 && + data[0] == tnefMagic[0] && + data[1] == tnefMagic[1] && + data[2] == tnefMagic[2] && + data[3] == tnefMagic[3] +} + +// isTNEFAttachment returns true if an attachment is a TNEF-encoded winmail.dat. +// Detection is based on filename, content-type, or the TNEF magic bytes. +func isTNEFAttachment(att EmailAttachment) bool { + filenameLower := strings.ToLower(att.Filename) + if filenameLower == "winmail.dat" { + return true + } + ctLower := strings.ToLower(att.ContentType) + if strings.Contains(ctLower, "application/ms-tnef") || + strings.Contains(ctLower, "application/vnd.ms-tnef") { + return true + } + return isTNEFData(att.Data) +} + +// extractTNEFAttachments decodes a TNEF blob and returns the files embedded +// inside it, recursively following nested embedded MAPI messages. +func extractTNEFAttachments(data []byte) ([]EmailAttachment, error) { + return extractTNEFRecursive(data, 0) +} + +func extractTNEFRecursive(data []byte, depth int) ([]EmailAttachment, error) { + if depth > maxTNEFDepth { + return nil, nil + } + + decoded, err := tnef.Decode(data) + if err != nil { + return nil, err + } + + var attachments []EmailAttachment + + // Collect non-placeholder file attachments from the library output. + for _, att := range decoded.Attachments { + if len(att.Data) == 0 { + continue + } + // Skip the small MAPI placeholder text ("L'allegato è un messaggio + // incorporato MAPI 1.0...") that Outlook inserts for embedded messages. + if isEmbeddedMsgPlaceholder(att) { + continue + } + + filename := att.Title + if filename == "" || filename == "Untitled Attachment" { + filename = inferFilename(att.Data) + } + + attachments = append(attachments, EmailAttachment{ + Filename: filename, + ContentType: mimeTypeFromFilename(filename), + Data: att.Data, + }) + } + + // Recursively dig into embedded MAPI messages stored in + // attAttachment (0x9005) → PR_ATTACH_DATA_OBJ (0x3701). + for _, stream := range findEmbeddedTNEFStreamsFromRaw(data) { + subAtts, _ := extractTNEFRecursive(stream, depth+1) + attachments = append(attachments, subAtts...) + } + + return attachments, nil +} + +// isEmbeddedMsgPlaceholder returns true if the attachment is a tiny placeholder +// that Outlook generates for embedded MAPI messages ("L'allegato è un messaggio +// incorporato MAPI 1.0" or equivalent in other languages). +func isEmbeddedMsgPlaceholder(att *tnef.Attachment) bool { + if len(att.Data) > 300 { + return false + } + lower := strings.ToLower(string(att.Data)) + return strings.Contains(lower, "mapi 1.0") || + strings.Contains(lower, "embedded message") || + strings.Contains(lower, "messaggio incorporato") +} + +// inferFilename picks a reasonable filename based on the data's magic bytes. +func inferFilename(data []byte) string { + if looksLikeEML(data) { + return "embedded_message.eml" + } + if isTNEFData(data) { + return "embedded.dat" + } + if len(data) >= 8 { + if data[0] == 0xD0 && data[1] == 0xCF && data[2] == 0x11 && data[3] == 0xE0 { + return "embedded_message.msg" + } + } + return "attachment.dat" +} + +// looksLikeEML returns true if data starts with typical RFC 5322 headers. +func looksLikeEML(data []byte) bool { + if len(data) < 20 { + return false + } + // Quick check: must start with printable ASCII + if data[0] < 32 || data[0] > 126 { + return false + } + prefix := strings.ToLower(string(data[:min(200, len(data))])) + return strings.HasPrefix(prefix, "mime-version:") || + strings.HasPrefix(prefix, "from:") || + strings.HasPrefix(prefix, "received:") || + strings.HasPrefix(prefix, "date:") || + strings.HasPrefix(prefix, "content-type:") || + strings.HasPrefix(prefix, "return-path:") +} + +// expandTNEFAttachments iterates over the attachment list and replaces any +// TNEF-encoded winmail.dat entries with the files they contain. Attachments +// that are not TNEF are passed through unchanged. +func expandTNEFAttachments(attachments []EmailAttachment) []EmailAttachment { + var result []EmailAttachment + for _, att := range attachments { + if isTNEFAttachment(att) { + extracted, err := extractTNEFAttachments(att.Data) + if err == nil && len(extracted) > 0 { + result = append(result, extracted...) + continue + } + // If extraction fails, keep the original blob. + } + result = append(result, att) + } + return result +} + +// --------------------------------------------------------------------------- +// Raw TNEF attribute scanner — extracts nested TNEF streams from embedded +// MAPI messages that the teamwork/tnef library does not handle. +// --------------------------------------------------------------------------- + +// findEmbeddedTNEFStreamsFromRaw scans the raw TNEF byte stream for +// attAttachment (0x00069005) attribute blocks, parses their MAPI properties, +// and extracts any PR_ATTACH_DATA_OBJ (0x3701) values that begin with a +// TNEF signature. +func findEmbeddedTNEFStreamsFromRaw(tnefData []byte) [][]byte { + if len(tnefData) < 6 || !isTNEFData(tnefData) { + return nil + } + + var streams [][]byte + offset := 6 // skip TNEF signature (4) + key (2) + + for offset+9 < len(tnefData) { + level := tnefData[offset] + attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9])) + dataStart := offset + 9 + + if dataStart+attrLen > len(tnefData) || attrLen < 0 { + break + } + + // attAttachment (0x00069005) at attachment level (0x02) + if level == 0x02 && attrID == 0x00069005 && attrLen > 100 { + mapiData := tnefData[dataStart : dataStart+attrLen] + embedded := extractPRAttachDataObjFromMAPI(mapiData) + if embedded != nil && len(embedded) > 22 { + // Skip the 16-byte IID_IMessage GUID + afterGuid := embedded[16:] + if isTNEFData(afterGuid) { + streams = append(streams, afterGuid) + } + } + } + + // level(1) + id(4) + len(4) + data(attrLen) + checksum(2) + offset += 9 + attrLen + 2 + } + return streams +} + +// extractPRAttachDataObjFromMAPI parses a MAPI properties block (from an +// attAttachment attribute) and returns the raw value of PR_ATTACH_DATA_OBJ +// (property ID 0x3701, type PT_OBJECT 0x000D). +func extractPRAttachDataObjFromMAPI(data []byte) []byte { + if len(data) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(data[0:4])) + off := 4 + + for i := 0; i < count && off+4 <= len(data); i++ { + propTag := binary.LittleEndian.Uint32(data[off : off+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + off += 4 + + // Named properties (ID >= 0x8000) have extra GUID + kind fields. + if propID >= 0x8000 { + if off+20 > len(data) { + return nil + } + kind := binary.LittleEndian.Uint32(data[off+16 : off+20]) + off += 20 + if kind == 0 { // MNID_ID + off += 4 + } else { // MNID_STRING + if off+4 > len(data) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + nameLen + off += padTo4(nameLen) + } + } + + off = skipMAPIPropValue(data, off, propType, propID) + if off < 0 { + return nil // parse error + } + // If skipMAPIPropValue returned a special sentinel, extract it. + // We use a hack: skipMAPIPropValue can't return the data directly, + // so we handle PT_OBJECT / 0x3701 inline below. + } + + // Simpler approach: re-scan specifically for 0x3701. + return extractPRAttachDataObjDirect(data) +} + +// extractPRAttachDataObjDirect re-scans the MAPI property block and +// returns the raw value of PR_ATTACH_DATA_OBJ (0x3701, PT_OBJECT). +func extractPRAttachDataObjDirect(data []byte) []byte { + if len(data) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(data[0:4])) + off := 4 + + for i := 0; i < count && off+4 <= len(data); i++ { + propTag := binary.LittleEndian.Uint32(data[off : off+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + off += 4 + + // Skip named property headers. + if propID >= 0x8000 { + if off+20 > len(data) { + return nil + } + kind := binary.LittleEndian.Uint32(data[off+16 : off+20]) + off += 20 + if kind == 0 { + off += 4 + } else { + if off+4 > len(data) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + nameLen + off += padTo4(nameLen) + } + } + + switch propType { + case 0x0002: // PT_SHORT (padded to 4) + off += 4 + case 0x0003, 0x000A: // PT_LONG, PT_ERROR + off += 4 + case 0x000B: // PT_BOOLEAN (padded to 4) + off += 4 + case 0x0004: // PT_FLOAT + off += 4 + case 0x0005: // PT_DOUBLE + off += 8 + case 0x0006: // PT_CURRENCY + off += 8 + case 0x0007: // PT_APPTIME + off += 8 + case 0x0014: // PT_I8 + off += 8 + case 0x0040: // PT_SYSTIME + off += 8 + case 0x0048: // PT_CLSID + off += 16 + case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE + off = skipCountedBlobs(data, off) + case 0x0102: // PT_BINARY + off = skipCountedBlobs(data, off) + case 0x000D: // PT_OBJECT + if off+4 > len(data) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + for j := 0; j < cnt; j++ { + if off+4 > len(data) { + return nil + } + olen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + if propID == 0x3701 && off+olen <= len(data) { + return data[off : off+olen] + } + off += olen + off += padTo4(olen) + } + case 0x1002: // PT_MV_SHORT + off = skipMVFixed(data, off, 4) + case 0x1003: // PT_MV_LONG + off = skipMVFixed(data, off, 4) + case 0x1005: // PT_MV_DOUBLE + off = skipMVFixed(data, off, 8) + case 0x1014: // PT_MV_I8 + off = skipMVFixed(data, off, 8) + case 0x1040: // PT_MV_SYSTIME + off = skipMVFixed(data, off, 8) + case 0x101E, 0x101F: // PT_MV_STRING8, PT_MV_UNICODE + off = skipCountedBlobs(data, off) + case 0x1048: // PT_MV_CLSID + off = skipMVFixed(data, off, 16) + case 0x1102: // PT_MV_BINARY + off = skipCountedBlobs(data, off) + default: + // Unknown type, can't continue + return nil + } + + if off < 0 || off > len(data) { + return nil + } + } + return nil +} + +// skipCountedBlobs advances past a MAPI value that stores count + N +// length-prefixed blobs (used by PT_STRING8, PT_UNICODE, PT_BINARY, and +// their multi-valued variants). +func skipCountedBlobs(data []byte, off int) int { + if off+4 > len(data) { + return -1 + } + cnt := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + for j := 0; j < cnt; j++ { + if off+4 > len(data) { + return -1 + } + blen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + blen + off += padTo4(blen) + } + return off +} + +// skipMVFixed advances past a multi-valued fixed-size property +// (count followed by count*elemSize bytes). +func skipMVFixed(data []byte, off int, elemSize int) int { + if off+4 > len(data) { + return -1 + } + cnt := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + cnt*elemSize + return off +} + +// skipMAPIPropValue is a generic value skipper (unused in the current flow +// but kept for completeness). +func skipMAPIPropValue(data []byte, off int, propType uint32, _ uint32) int { + switch propType { + case 0x0002: + return off + 4 + case 0x0003, 0x000A, 0x000B, 0x0004: + return off + 4 + case 0x0005, 0x0006, 0x0007, 0x0014, 0x0040: + return off + 8 + case 0x0048: + return off + 16 + case 0x001E, 0x001F, 0x0102, 0x000D: + return skipCountedBlobs(data, off) + case 0x1002, 0x1003: + return skipMVFixed(data, off, 4) + case 0x1005, 0x1014, 0x1040: + return skipMVFixed(data, off, 8) + case 0x1048: + return skipMVFixed(data, off, 16) + case 0x101E, 0x101F, 0x1102: + return skipCountedBlobs(data, off) + default: + return -1 + } +} + +// padTo4 returns the number of padding bytes needed to reach a 4-byte boundary. +func padTo4(n int) int { + r := n % 4 + if r == 0 { + return 0 + } + return 4 - r +} + +// --------------------------------------------------------------------------- +// MIME type helper +// --------------------------------------------------------------------------- + +// mimeTypeFromFilename guesses the MIME type from a file extension. +// Falls back to "application/octet-stream" when the type is unknown. +func mimeTypeFromFilename(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + return "application/octet-stream" + } + t := mime.TypeByExtension(ext) + if t == "" { + return "application/octet-stream" + } + // Strip any parameters (e.g. "; charset=utf-8") + if idx := strings.Index(t, ";"); idx != -1 { + t = strings.TrimSpace(t[:idx]) + } + return t +} diff --git a/backend/utils/mail/tnef_reader_test.go b/backend/utils/mail/tnef_reader_test.go new file mode 100644 index 0000000..20e659a --- /dev/null +++ b/backend/utils/mail/tnef_reader_test.go @@ -0,0 +1,59 @@ +package internal + +import ( + "fmt" + "os" + "testing" +) + +func TestReadEmlWithTNEF(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + // First try the PEC reader (this is a PEC email) + email, err := ReadPecInnerEml(testFile) + if err != nil { + t.Fatalf("ReadPecInnerEml failed: %v", err) + } + + fmt.Printf("Subject: %s\n", email.Subject) + fmt.Printf("From: %s\n", email.From) + fmt.Printf("Attachment count: %d\n", len(email.Attachments)) + + hasWinmailDat := false + for i, att := range email.Attachments { + fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data)) + if att.Filename == "winmail.dat" { + hasWinmailDat = true + } + } + + if hasWinmailDat { + t.Error("winmail.dat should have been expanded into its contained attachments") + } + + if len(email.Attachments) == 0 { + t.Error("expected at least one attachment after TNEF expansion") + } +} + +func TestReadEmlFallback(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + // Also verify the plain EML reader path + email, err := ReadEmlFile(testFile) + if err != nil { + t.Fatalf("ReadEmlFile failed: %v", err) + } + + fmt.Printf("[EML] Subject: %s\n", email.Subject) + fmt.Printf("[EML] Attachment count: %d\n", len(email.Attachments)) + for i, att := range email.Attachments { + fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data)) + } +} diff --git a/frontend/bun.lock b/frontend/bun.lock index ebfc404..7020053 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,7 +5,10 @@ "": { "name": "frontend", "dependencies": { + "@rollup/rollup-win32-arm64-msvc": "^4.57.1", + "@types/html2canvas": "^1.0.0", "dompurify": "^3.3.1", + "html2canvas": "^1.4.1", "pdfjs-dist": "^5.4.624", "svelte-flags": "^3.0.1", "svelte-sonner": "^1.0.7", @@ -187,7 +190,7 @@ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="], @@ -249,6 +252,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/html2canvas": ["@types/html2canvas@1.0.0", "", { "dependencies": { "html2canvas": "*" } }, "sha512-BJpVf+FIN9UERmzhbtUgpXj6XBZpG67FMgBLLoj9HZKd9XifcCpSV+UnFcwTZfEyun4U/KmCrrVOG7829L589w=="], + "@types/node": ["@types/node@24.10.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -261,6 +266,8 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "bits-ui": ["bits-ui@2.15.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -277,6 +284,8 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], @@ -307,6 +316,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -415,6 +426,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], @@ -433,6 +446,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], @@ -469,6 +484,8 @@ "paneforge/svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="], + "rollup/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + "svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="], "mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="], diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 646b398..65bd0b1 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -3c4a64d0cfb34e86fac16fceae842e43 \ No newline at end of file +1697d40a08e09716b8c29ddebeabd1ad \ No newline at end of file diff --git a/frontend/src/lib/utils/mail/email-loader.ts b/frontend/src/lib/utils/mail/email-loader.ts index 69b7b0f..c942b4e 100644 --- a/frontend/src/lib/utils/mail/email-loader.ts +++ b/frontend/src/lib/utils/mail/email-loader.ts @@ -6,6 +6,8 @@ import { ReadEML, ReadMSG, ReadPEC, + ReadAuto, + DetectEmailFormat, ShowOpenFileDialog, SetCurrentMailFilePath, ConvertToUTF8, @@ -23,7 +25,8 @@ export interface LoadEmailResult { } /** - * Determines the email file type from the path + * Determines the email file type from the path extension (best-effort hint). + * Use DetectEmailFormat (backend) for reliable format detection. */ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null { const lowerPath = filePath.toLowerCase(); @@ -33,18 +36,57 @@ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null { } /** - * Checks if a file path is a valid email file + * Checks if a file path looks like an email file by extension. + * Returns true also for unknown extensions so the backend can attempt parsing. */ export function isEmailFile(filePath: string): boolean { - return getEmailFileType(filePath) !== null; + return filePath.trim().length > 0; } /** - * Loads an email from a file path + * Loads an email from a file path. + * Uses ReadAuto so the backend detects the format from the file's binary + * content, regardless of extension. Falls back to the legacy per-format + * readers only when the caller explicitly requests them. + * * @param filePath - Path to the email file * @returns LoadEmailResult with the email data or error */ export async function loadEmailFromPath(filePath: string): Promise { + if (!filePath?.trim()) { + return { success: false, error: 'No file path provided.' }; + } + + try { + // ReadAuto detects the format (EML/PEC/MSG) by magic bytes and dispatches + // to the appropriate reader. This works for any extension, including + // unconventional ones like winmail.dat or no extension at all. + const email = await ReadAuto(filePath); + + // Process body if needed (decode base64) + if (email?.body) { + const trimmed = email.body.trim(); + if (looksLikeBase64(trimmed)) { + const decoded = tryDecodeBase64(trimmed); + if (decoded) { + email.body = decoded; + } + } + } + + return { success: true, email, filePath }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Failed to load email:', error); + return { success: false, error: errorMessage }; + } +} + +/** + * Loads an email using the explicit per-format readers (legacy path). + * Prefer loadEmailFromPath for new code. + */ +export async function loadEmailFromPathLegacy(filePath: string): Promise { const fileType = getEmailFileType(filePath); if (!fileType) { @@ -60,7 +102,6 @@ export async function loadEmailFromPath(filePath: string): Promise; + export function CheckIsDefaultEMLHandler():Promise; +export function ConvertToUTF8(arg1:string):Promise; + +export function CreateBugReportFolder():Promise; + +export function DetectEmailFormat(arg1:string):Promise; + +export function DownloadUpdate():Promise; + +export function ExportSettings(arg1:string):Promise; + export function FrontendLog(arg1:string,arg2:string):Promise; export function GetConfig():Promise; +export function GetCurrentMailFilePath():Promise; + export function GetImageViewerData():Promise; export function GetMachineData():Promise; @@ -18,14 +32,26 @@ export function GetPDFViewerData():Promise; export function GetStartupFile():Promise; +export function GetUpdateStatus():Promise; + export function GetViewerData():Promise; +export function ImportSettings():Promise; + +export function InstallUpdate(arg1:boolean):Promise; + +export function InstallUpdateSilent():Promise; + +export function InstallUpdateSilentFromPath(arg1:string):Promise; + export function IsDebuggerRunning():Promise; export function OpenDefaultAppsSettings():Promise; export function OpenEMLWindow(arg1:string,arg2:string):Promise; +export function OpenFolderInExplorer(arg1:string):Promise; + export function OpenImage(arg1:string,arg2:string):Promise; export function OpenImageWindow(arg1:string,arg2:string):Promise; @@ -34,8 +60,12 @@ export function OpenPDF(arg1:string,arg2:string):Promise; export function OpenPDFWindow(arg1:string,arg2:string):Promise; +export function OpenURLInBrowser(arg1:string):Promise; + export function QuitApp():Promise; +export function ReadAuto(arg1:string):Promise; + export function ReadEML(arg1:string):Promise; export function ReadMSG(arg1:string,arg2:boolean):Promise; @@ -46,4 +76,16 @@ export function ReadPEC(arg1:string):Promise; export function SaveConfig(arg1:utils.Config):Promise; +export function SaveScreenshot():Promise; + +export function SaveScreenshotAs():Promise; + +export function SetCurrentMailFilePath(arg1:string):Promise; + +export function SetUpdateCheckerEnabled(arg1:boolean):Promise; + export function ShowOpenFileDialog():Promise; + +export function SubmitBugReport(arg1:main.BugReportInput):Promise; + +export function TakeScreenshot():Promise; diff --git a/frontend/src/lib/wailsjs/go/main/App.js b/frontend/src/lib/wailsjs/go/main/App.js index 228744d..27b31d9 100644 --- a/frontend/src/lib/wailsjs/go/main/App.js +++ b/frontend/src/lib/wailsjs/go/main/App.js @@ -2,10 +2,34 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CheckForUpdates() { + return window['go']['main']['App']['CheckForUpdates'](); +} + export function CheckIsDefaultEMLHandler() { return window['go']['main']['App']['CheckIsDefaultEMLHandler'](); } +export function ConvertToUTF8(arg1) { + return window['go']['main']['App']['ConvertToUTF8'](arg1); +} + +export function CreateBugReportFolder() { + return window['go']['main']['App']['CreateBugReportFolder'](); +} + +export function DetectEmailFormat(arg1) { + return window['go']['main']['App']['DetectEmailFormat'](arg1); +} + +export function DownloadUpdate() { + return window['go']['main']['App']['DownloadUpdate'](); +} + +export function ExportSettings(arg1) { + return window['go']['main']['App']['ExportSettings'](arg1); +} + export function FrontendLog(arg1, arg2) { return window['go']['main']['App']['FrontendLog'](arg1, arg2); } @@ -14,6 +38,10 @@ export function GetConfig() { return window['go']['main']['App']['GetConfig'](); } +export function GetCurrentMailFilePath() { + return window['go']['main']['App']['GetCurrentMailFilePath'](); +} + export function GetImageViewerData() { return window['go']['main']['App']['GetImageViewerData'](); } @@ -30,10 +58,30 @@ export function GetStartupFile() { return window['go']['main']['App']['GetStartupFile'](); } +export function GetUpdateStatus() { + return window['go']['main']['App']['GetUpdateStatus'](); +} + export function GetViewerData() { return window['go']['main']['App']['GetViewerData'](); } +export function ImportSettings() { + return window['go']['main']['App']['ImportSettings'](); +} + +export function InstallUpdate(arg1) { + return window['go']['main']['App']['InstallUpdate'](arg1); +} + +export function InstallUpdateSilent() { + return window['go']['main']['App']['InstallUpdateSilent'](); +} + +export function InstallUpdateSilentFromPath(arg1) { + return window['go']['main']['App']['InstallUpdateSilentFromPath'](arg1); +} + export function IsDebuggerRunning() { return window['go']['main']['App']['IsDebuggerRunning'](); } @@ -46,6 +94,10 @@ export function OpenEMLWindow(arg1, arg2) { return window['go']['main']['App']['OpenEMLWindow'](arg1, arg2); } +export function OpenFolderInExplorer(arg1) { + return window['go']['main']['App']['OpenFolderInExplorer'](arg1); +} + export function OpenImage(arg1, arg2) { return window['go']['main']['App']['OpenImage'](arg1, arg2); } @@ -62,10 +114,18 @@ export function OpenPDFWindow(arg1, arg2) { return window['go']['main']['App']['OpenPDFWindow'](arg1, arg2); } +export function OpenURLInBrowser(arg1) { + return window['go']['main']['App']['OpenURLInBrowser'](arg1); +} + export function QuitApp() { return window['go']['main']['App']['QuitApp'](); } +export function ReadAuto(arg1) { + return window['go']['main']['App']['ReadAuto'](arg1); +} + export function ReadEML(arg1) { return window['go']['main']['App']['ReadEML'](arg1); } @@ -86,6 +146,30 @@ export function SaveConfig(arg1) { return window['go']['main']['App']['SaveConfig'](arg1); } +export function SaveScreenshot() { + return window['go']['main']['App']['SaveScreenshot'](); +} + +export function SaveScreenshotAs() { + return window['go']['main']['App']['SaveScreenshotAs'](); +} + +export function SetCurrentMailFilePath(arg1) { + return window['go']['main']['App']['SetCurrentMailFilePath'](arg1); +} + +export function SetUpdateCheckerEnabled(arg1) { + return window['go']['main']['App']['SetUpdateCheckerEnabled'](arg1); +} + export function ShowOpenFileDialog() { return window['go']['main']['App']['ShowOpenFileDialog'](); } + +export function SubmitBugReport(arg1) { + return window['go']['main']['App']['SubmitBugReport'](arg1); +} + +export function TakeScreenshot() { + return window['go']['main']['App']['TakeScreenshot'](); +} diff --git a/frontend/src/lib/wailsjs/go/models.ts b/frontend/src/lib/wailsjs/go/models.ts index dfe35d7..9389afe 100644 --- a/frontend/src/lib/wailsjs/go/models.ts +++ b/frontend/src/lib/wailsjs/go/models.ts @@ -242,6 +242,44 @@ export namespace internal { export namespace main { + export class BugReportInput { + name: string; + email: string; + description: string; + screenshotData: string; + localStorageData: string; + configData: string; + + static createFrom(source: any = {}) { + return new BugReportInput(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.email = source["email"]; + this.description = source["description"]; + this.screenshotData = source["screenshotData"]; + this.localStorageData = source["localStorageData"]; + this.configData = source["configData"]; + } + } + export class BugReportResult { + folderPath: string; + screenshotPath: string; + mailFilePath: string; + + static createFrom(source: any = {}) { + return new BugReportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.folderPath = source["folderPath"]; + this.screenshotPath = source["screenshotPath"]; + this.mailFilePath = source["mailFilePath"]; + } + } export class ImageViewerData { data: string; filename: string; @@ -270,6 +308,70 @@ export namespace main { this.filename = source["filename"]; } } + export class ScreenshotResult { + data: string; + width: number; + height: number; + filename: string; + + static createFrom(source: any = {}) { + return new ScreenshotResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.data = source["data"]; + this.width = source["width"]; + this.height = source["height"]; + this.filename = source["filename"]; + } + } + export class SubmitBugReportResult { + zipPath: string; + folderPath: string; + + static createFrom(source: any = {}) { + return new SubmitBugReportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.zipPath = source["zipPath"]; + this.folderPath = source["folderPath"]; + } + } + export class UpdateStatus { + currentVersion: string; + availableVersion: string; + updateAvailable: boolean; + checking: boolean; + downloading: boolean; + downloadProgress: number; + ready: boolean; + installerPath: string; + errorMessage: string; + releaseNotes?: string; + lastCheckTime: string; + + static createFrom(source: any = {}) { + return new UpdateStatus(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.currentVersion = source["currentVersion"]; + this.availableVersion = source["availableVersion"]; + this.updateAvailable = source["updateAvailable"]; + this.checking = source["checking"]; + this.downloading = source["downloading"]; + this.downloadProgress = source["downloadProgress"]; + this.ready = source["ready"]; + this.installerPath = source["installerPath"]; + this.errorMessage = source["errorMessage"]; + this.releaseNotes = source["releaseNotes"]; + this.lastCheckTime = source["lastCheckTime"]; + } + } export class ViewerData { imageData?: ImageViewerData; pdfData?: PDFViewerData; @@ -717,6 +819,10 @@ export namespace utils { SDKDecoderReleaseChannel: string; GUISemver: string; GUIReleaseChannel: string; + Language: string; + UpdateCheckEnabled: string; + UpdatePath: string; + UpdateAutoCheck: string; static createFrom(source: any = {}) { return new EMLyConfig(source); @@ -728,6 +834,10 @@ export namespace utils { this.SDKDecoderReleaseChannel = source["SDKDecoderReleaseChannel"]; this.GUISemver = source["GUISemver"]; this.GUIReleaseChannel = source["GUIReleaseChannel"]; + this.Language = source["Language"]; + this.UpdateCheckEnabled = source["UpdateCheckEnabled"]; + this.UpdatePath = source["UpdatePath"]; + this.UpdateAutoCheck = source["UpdateAutoCheck"]; } } export class Config { diff --git a/go.mod b/go.mod index e1e8842..70b57cd 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.4 require ( github.com/jaypipes/ghw v0.21.2 + github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32 github.com/wailsapp/wails/v2 v2.11.0 golang.org/x/sys v0.40.0 golang.org/x/text v0.22.0 @@ -30,6 +31,8 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad // indirect + github.com/teamwork/utils v1.0.0 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect diff --git a/go.sum b/go.sum index 9c440d3..bb2bed3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/Strum355/go-difflib v1.1.0/go.mod h1:r1cVg1JkGsTWkaR7At56v7hfuMgiUL8meTLwxFzOmvE= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -47,6 +48,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -65,6 +67,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/teamwork/test v0.0.0-20190410143529-8897d82f8d46/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA= +github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad h1:25sEr0awm0ZPancg5W5H5VvN7PWsJloUBpii10a9isw= +github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA= +github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32 h1:j15wq0XPAY/HR/0+dtwUrIrF2ZTKbk7QIES2p4dAG+k= +github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32/go.mod h1:v7dFaQrF/4+curx7UTH9rqTkHTgXqghfI3thANW150o= +github.com/teamwork/utils v1.0.0 h1:30WqhSbZ9nFhaJSx9HH+yFLiQvL64nqAOyyl5IxoYlY= +github.com/teamwork/utils v1.0.0/go.mod h1:3Fn0qxFeRNpvsg/9T1+btOOOKkd1qG2nPYKKcOmNpcs= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -- 2.47.3 From d510c24b6945e8c4f6d1a77ef163bb94763ea3e1 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Sat, 14 Feb 2026 21:07:53 +0100 Subject: [PATCH 02/14] feat: implement bug report submission with server upload functionality - Updated documentation to include new API server details and configuration options. - Enhanced `SubmitBugReport` method to attempt server upload and handle errors gracefully. - Added `UploadBugReport` method to handle multipart file uploads to the API server. - Introduced new API server with MySQL backend for managing bug reports. - Implemented rate limiting and authentication for the API. - Created database schema and migration scripts for bug report storage. - Added admin routes for managing bug reports and files. - Updated frontend to reflect changes in bug report submission and success/error messages. --- DOCUMENTATION.md | 24 ++- app_bugreport.go | 166 +++++++++++++++++- backend/utils/ini-reader.go | 2 + config.ini | 2 + frontend/messages/en.json | 5 +- frontend/messages/it.json | 6 +- .../src/lib/components/BugReportDialog.svelte | 45 ++++- server/.env.example | 18 ++ server/.gitignore | 4 + server/Dockerfile | 13 ++ server/docker-compose.yml | 40 +++++ server/package.json | 17 ++ server/src/config.ts | 24 +++ server/src/db/connection.ts | 28 +++ server/src/db/migrate.ts | 37 ++++ server/src/db/schema.sql | 38 ++++ server/src/index.ts | 43 +++++ server/src/middleware/auth.ts | 24 +++ server/src/middleware/rateLimit.ts | 70 ++++++++ server/src/routes/admin.ts | 104 +++++++++++ server/src/routes/bugReports.ts | 101 +++++++++++ server/src/services/bugReportService.ts | 163 +++++++++++++++++ server/src/types/index.ts | 57 ++++++ server/tsconfig.json | 15 ++ 24 files changed, 1033 insertions(+), 13 deletions(-) create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/docker-compose.yml create mode 100644 server/package.json create mode 100644 server/src/config.ts create mode 100644 server/src/db/connection.ts create mode 100644 server/src/db/migrate.ts create mode 100644 server/src/db/schema.sql create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/auth.ts create mode 100644 server/src/middleware/rateLimit.ts create mode 100644 server/src/routes/admin.ts create mode 100644 server/src/routes/bugReports.ts create mode 100644 server/src/services/bugReportService.ts create mode 100644 server/src/types/index.ts create mode 100644 server/tsconfig.json diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6ec0e1e..edc3e56 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -252,7 +252,8 @@ The Go backend is split into logical files: | Method | Description | |--------|-------------| | `CreateBugReportFolder()` | Creates folder with screenshot and mail file | -| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive | +| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive, attempts server upload | +| `UploadBugReport(folderPath, input)` | Uploads bug report files to configured API server via multipart POST | **Settings (`app_settings.go`)** @@ -672,7 +673,26 @@ Complete bug reporting system: 3. Includes current mail file if loaded 4. Gathers system information 5. Creates ZIP archive in temp folder -6. Shows path and allows opening folder +6. Attempts to upload to the bug report API server (if configured) +7. Falls back to local ZIP if server is unreachable +8. Shows server confirmation with report ID, or local path with upload warning + +#### Bug Report API Server + +A separate API server (`server/` directory) receives bug reports: +- **Stack**: Bun.js + ElysiaJS + MySQL 8 +- **Deployment**: Docker Compose (`docker compose up -d` from `server/`) +- **Auth**: Static API key for clients (`X-API-Key`), static admin key (`X-Admin-Key`) +- **Rate limiting**: HWID-based, configurable (default 5 reports per 24h) +- **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin) + +#### Configuration (config.ini) + +```ini +[EMLy] +BUGREPORT_API_URL="https://your-server.example.com" +BUGREPORT_API_KEY="your-api-key" +``` ### 5. Settings Management diff --git a/app_bugreport.go b/app_bugreport.go index 4b2fc0f..204187e 100644 --- a/app_bugreport.go +++ b/app_bugreport.go @@ -5,8 +5,13 @@ package main import ( "archive/zip" + "bytes" "encoding/base64" + "encoding/json" "fmt" + "io" + "mime/multipart" + "net/http" "os" "path/filepath" "time" @@ -50,6 +55,12 @@ type SubmitBugReportResult struct { ZipPath string `json:"zipPath"` // FolderPath is the path to the bug report folder FolderPath string `json:"folderPath"` + // Uploaded indicates whether the report was successfully uploaded to the server + Uploaded bool `json:"uploaded"` + // ReportID is the server-assigned report ID (0 if not uploaded) + ReportID int64 `json:"reportId"` + // UploadError contains the error message if upload failed (empty on success) + UploadError string `json:"uploadError"` } // ============================================================================= @@ -233,10 +244,161 @@ External IP: %s return nil, fmt.Errorf("failed to create zip file: %w", err) } - return &SubmitBugReportResult{ + result := &SubmitBugReportResult{ ZipPath: zipPath, FolderPath: bugReportFolder, - }, nil + } + + // Attempt to upload to the bug report API server + reportID, uploadErr := a.UploadBugReport(bugReportFolder, input) + if uploadErr != nil { + Log("Bug report upload failed (falling back to local zip):", uploadErr) + result.UploadError = uploadErr.Error() + } else { + result.Uploaded = true + result.ReportID = reportID + } + + return result, nil +} + +// UploadBugReport uploads the bug report files from the temp folder to the +// configured API server. Returns the server-assigned report ID on success. +// +// Parameters: +// - folderPath: Path to the bug report folder containing the files +// - input: Original bug report input with user details +// +// Returns: +// - int64: Server-assigned report ID +// - error: Error if upload fails or API is not configured +func (a *App) UploadBugReport(folderPath string, input BugReportInput) (int64, error) { + // Load config to get API URL and key + cfgPath := utils.DefaultConfigPath() + cfg, err := utils.LoadConfig(cfgPath) + if err != nil { + return 0, fmt.Errorf("failed to load config: %w", err) + } + + apiURL := cfg.EMLy.BugReportAPIURL + apiKey := cfg.EMLy.BugReportAPIKey + + if apiURL == "" { + return 0, fmt.Errorf("bug report API URL not configured") + } + if apiKey == "" { + return 0, fmt.Errorf("bug report API key not configured") + } + + // Build multipart form + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Add text fields + writer.WriteField("name", input.Name) + writer.WriteField("email", input.Email) + writer.WriteField("description", input.Description) + + // Add machine identification fields + machineInfo, err := utils.GetMachineInfo() + if err == nil && machineInfo != nil { + writer.WriteField("hwid", machineInfo.HWID) + writer.WriteField("hostname", machineInfo.Hostname) + + // Add system_info as JSON string + sysInfoJSON, jsonErr := json.Marshal(machineInfo) + if jsonErr == nil { + writer.WriteField("system_info", string(sysInfoJSON)) + } + } + + // Add current OS username + if currentUser, userErr := os.UserHomeDir(); userErr == nil { + writer.WriteField("os_user", filepath.Base(currentUser)) + } + + // Add files from the folder + fileRoles := map[string]string{ + "screenshot": "screenshot", + "mail_file": "mail_file", + "localStorage.json": "localstorage", + "config.json": "config", + } + + entries, _ := os.ReadDir(folderPath) + for _, entry := range entries { + if entry.IsDir() { + continue + } + filename := entry.Name() + + // Determine file role + var role string + for pattern, r := range fileRoles { + if filename == pattern { + role = r + break + } + } + // Match screenshot and mail files by prefix/extension + if role == "" { + if filepath.Ext(filename) == ".png" { + role = "screenshot" + } else if filepath.Ext(filename) == ".eml" || filepath.Ext(filename) == ".msg" { + role = "mail_file" + } + } + if role == "" { + continue // skip report.txt and system_info.txt (sent as fields) + } + + filePath := filepath.Join(folderPath, filename) + fileData, readErr := os.ReadFile(filePath) + if readErr != nil { + continue + } + + part, partErr := writer.CreateFormFile(role, filename) + if partErr != nil { + continue + } + part.Write(fileData) + } + + writer.Close() + + // Send HTTP request + endpoint := apiURL + "/api/bug-reports" + req, err := http.NewRequest("POST", endpoint, &buf) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-API-Key", apiKey) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusCreated { + return 0, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response + var response struct { + Success bool `json:"success"` + ReportID int64 `json:"report_id"` + } + if err := json.Unmarshal(body, &response); err != nil { + return 0, fmt.Errorf("failed to parse response: %w", err) + } + + return response.ReportID, nil } // ============================================================================= diff --git a/backend/utils/ini-reader.go b/backend/utils/ini-reader.go index 44be631..ba0d115 100644 --- a/backend/utils/ini-reader.go +++ b/backend/utils/ini-reader.go @@ -22,6 +22,8 @@ type EMLyConfig struct { UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"` UpdatePath string `ini:"UPDATE_PATH"` UpdateAutoCheck string `ini:"UPDATE_AUTO_CHECK"` + BugReportAPIURL string `ini:"BUGREPORT_API_URL"` + BugReportAPIKey string `ini:"BUGREPORT_API_KEY"` } // LoadConfig reads the config.ini file at the given path and returns a Config struct diff --git a/config.ini b/config.ini index 601a4f8..904ff7f 100644 --- a/config.ini +++ b/config.ini @@ -7,3 +7,5 @@ LANGUAGE = it UPDATE_CHECK_ENABLED = false UPDATE_PATH = UPDATE_AUTO_CHECK = true +BUGREPORT_API_URL = "http://localhost:3000" +BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63" \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 969600e..69f5daf 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -218,5 +218,8 @@ "pdf_error_no_data_desc": "No PDF data provided. Please open this window from the main EMLy application.", "pdf_error_timeout": "Timeout loading PDF. The worker might have failed to initialize.", "pdf_error_parsing": "Error parsing PDF: ", - "pdf_error_rendering": "Error rendering page: " + "pdf_error_rendering": "Error rendering page: ", + "bugreport_uploaded_success": "Your bug report has been uploaded successfully! Report ID: #{reportId}", + "bugreport_upload_failed": "Could not upload to server. Your report has been saved locally instead.", + "bugreport_uploaded_title": "Bug Report Uploaded" } diff --git a/frontend/messages/it.json b/frontend/messages/it.json index ad7f190..e0708fe 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -218,6 +218,8 @@ "pdf_error_rendering": "Errore nel rendering della pagina: ", "mail_download_btn_label": "Scarica", "mail_download_btn_title": "Scarica", - "mail_download_btn_text": "Scarica" - + "mail_download_btn_text": "Scarica", + "bugreport_uploaded_success": "La tua segnalazione è stata caricata con successo! ID segnalazione: #{reportId}", + "bugreport_upload_failed": "Impossibile caricare sul server. La segnalazione è stata salvata localmente.", + "bugreport_uploaded_title": "Segnalazione Bug Caricata" } diff --git a/frontend/src/lib/components/BugReportDialog.svelte b/frontend/src/lib/components/BugReportDialog.svelte index 84a352d..421550f 100644 --- a/frontend/src/lib/components/BugReportDialog.svelte +++ b/frontend/src/lib/components/BugReportDialog.svelte @@ -6,16 +6,24 @@ import { Input } from "$lib/components/ui/input/index.js"; import { Label } from "$lib/components/ui/label/index.js"; import { Textarea } from "$lib/components/ui/textarea/index.js"; - import { CheckCircle, Copy, FolderOpen, Camera, Loader2 } from "@lucide/svelte"; + import { CheckCircle, Copy, FolderOpen, Camera, Loader2, CloudUpload, AlertTriangle } from "@lucide/svelte"; import { toast } from "svelte-sonner"; import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App"; import { browser } from "$app/environment"; + import { dev } from "$app/environment"; // Bug report form state let userName = $state(""); let userEmail = $state(""); let bugDescription = $state(""); - + // Auto-fill form in dev mode + $effect(() => { + if (dev && $bugReportDialogOpen && !userName) { + userName = "Test User"; + userEmail = "test@example.com"; + bugDescription = "This is a test bug report submitted from development mode."; + } + }); // Bug report screenshot state let screenshotData = $state(""); let isCapturing = $state(false); @@ -28,6 +36,9 @@ let isSubmitting = $state(false); let isSuccess = $state(false); let resultZipPath = $state(""); + let uploadedToServer = $state(false); + let serverReportId = $state(0); + let uploadError = $state(""); let canSubmit: boolean = $derived( bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing ); @@ -100,6 +111,9 @@ isSubmitting = false; isSuccess = false; resultZipPath = ""; + uploadedToServer = false; + serverReportId = 0; + uploadError = ""; } async function handleBugReportSubmit(event: Event) { @@ -123,8 +137,11 @@ }); resultZipPath = result.zipPath; + uploadedToServer = result.uploaded; + serverReportId = result.reportId; + uploadError = result.uploadError; isSuccess = true; - console.log("Bug report created:", result.zipPath); + console.log("Bug report created:", result.zipPath, "uploaded:", result.uploaded); } catch (err) { console.error("Failed to create bug report:", err); toast.error(m.bugreport_error()); @@ -162,15 +179,31 @@ - - {m.bugreport_success_title()} + {#if uploadedToServer} + + {m.bugreport_uploaded_title()} + {:else} + + {m.bugreport_success_title()} + {/if} - {m.bugreport_success_message()} + {#if uploadedToServer} + {m.bugreport_uploaded_success({ reportId: String(serverReportId) })} + {:else} + {m.bugreport_success_message()} + {/if}
+ {#if uploadError} +
+ +

{m.bugreport_upload_failed()}

+
+ {/if} +
{resultZipPath}
diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..6b1cd43 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,18 @@ +# MySQL +MYSQL_HOST=mysql +MYSQL_PORT=3306 +MYSQL_USER=emly +MYSQL_PASSWORD=change_me_in_production +MYSQL_DATABASE=emly_bugreports +MYSQL_ROOT_PASSWORD=change_root_password + +# API Keys +API_KEY=change_me_client_key +ADMIN_KEY=change_me_admin_key + +# Server +PORT=3000 + +# Rate Limiting +RATE_LIMIT_MAX=5 +RATE_LIMIT_WINDOW_HOURS=24 diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..d21b1cb --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +dist/ +*.log diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..1eefc79 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,13 @@ +FROM oven/bun:alpine + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +COPY tsconfig.json ./ +COPY src/ ./src/ + +EXPOSE 3000 + +CMD ["bun", "run", "src/index.ts"] diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..ff3f75c --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,40 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + api: + build: . + ports: + - "${PORT:-3000}:3000" + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + API_KEY: ${API_KEY} + ADMIN_KEY: ${ADMIN_KEY} + PORT: 3000 + RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5} + RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24} + depends_on: + mysql: + condition: service_healthy + +volumes: + mysql_data: diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..987b510 --- /dev/null +++ b/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "emly-bugreport-server", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "elysia": "^1.2.0", + "mysql2": "^3.11.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0" + } +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..ec1932b --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,24 @@ +export const config = { + mysql: { + host: process.env.MYSQL_HOST || "localhost", + port: parseInt(process.env.MYSQL_PORT || "3306"), + user: process.env.MYSQL_USER || "emly", + password: process.env.MYSQL_PASSWORD || "", + database: process.env.MYSQL_DATABASE || "emly_bugreports", + }, + apiKey: process.env.API_KEY || "", + adminKey: process.env.ADMIN_KEY || "", + port: parseInt(process.env.PORT || "3000"), + rateLimit: { + max: parseInt(process.env.RATE_LIMIT_MAX || "5"), + windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"), + }, +} as const; + +// Validate required config on startup +export function validateConfig(): void { + if (!config.apiKey) throw new Error("API_KEY is required"); + if (!config.adminKey) throw new Error("ADMIN_KEY is required"); + if (!config.mysql.password) + throw new Error("MYSQL_PASSWORD is required"); +} diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts new file mode 100644 index 0000000..6966d0a --- /dev/null +++ b/server/src/db/connection.ts @@ -0,0 +1,28 @@ +import mysql from "mysql2/promise"; +import { config } from "../config"; + +let pool: mysql.Pool | null = null; + +export function getPool(): mysql.Pool { + if (!pool) { + pool = mysql.createPool({ + host: config.mysql.host, + port: config.mysql.port, + user: config.mysql.user, + password: config.mysql.password, + database: config.mysql.database, + waitForConnections: true, + connectionLimit: 10, + maxIdle: 5, + idleTimeout: 60000, + }); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts new file mode 100644 index 0000000..ad65da3 --- /dev/null +++ b/server/src/db/migrate.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { getPool } from "./connection"; + +export async function runMigrations(): Promise { + const pool = getPool(); + const schemaPath = join(import.meta.dir, "schema.sql"); + const schema = readFileSync(schemaPath, "utf-8"); + + // Split on semicolons, filter empty statements + const statements = schema + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + for (const statement of statements) { + await pool.execute(statement); + } + + // Additive migrations for existing databases + const alterMigrations = [ + `ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid`, + `ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname`, + `ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname)`, + `ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user)`, + ]; + + for (const migration of alterMigrations) { + try { + await pool.execute(migration); + } catch { + // Column/index already exists — safe to ignore + } + } + + console.log("Database migrations completed"); +} diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql new file mode 100644 index 0000000..034406c --- /dev/null +++ b/server/src/db/schema.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS `bug_reports` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `description` TEXT NOT NULL, + `hwid` VARCHAR(255) NOT NULL DEFAULT '', + `hostname` VARCHAR(255) NOT NULL DEFAULT '', + `os_user` VARCHAR(255) NOT NULL DEFAULT '', + `submitter_ip` VARCHAR(45) NOT NULL DEFAULT '', + `system_info` JSON NULL, + `status` ENUM('new', 'in_review', 'resolved', 'closed') NOT NULL DEFAULT 'new', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_status` (`status`), + INDEX `idx_hwid` (`hwid`), + INDEX `idx_hostname` (`hostname`), + INDEX `idx_os_user` (`os_user`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `bug_report_files` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `report_id` INT UNSIGNED NOT NULL, + `file_role` ENUM('screenshot', 'mail_file', 'localstorage', 'config', 'system_info') NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `mime_type` VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream', + `file_size` INT UNSIGNED NOT NULL DEFAULT 0, + `data` LONGBLOB NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `fk_report` FOREIGN KEY (`report_id`) REFERENCES `bug_reports`(`id`) ON DELETE CASCADE, + INDEX `idx_report_id` (`report_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `rate_limit_hwid` ( + `hwid` VARCHAR(255) PRIMARY KEY, + `window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `count` INT UNSIGNED NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..d6e9887 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,43 @@ +import { Elysia } from "elysia"; +import { config, validateConfig } from "./config"; +import { runMigrations } from "./db/migrate"; +import { closePool } from "./db/connection"; +import { bugReportRoutes } from "./routes/bugReports"; +import { adminRoutes } from "./routes/admin"; + +// Validate environment +validateConfig(); + +// Run database migrations +await runMigrations(); + +const app = new Elysia() + .onError(({ error, set }) => { + console.error("Unhandled error:", error); + set.status = 500; + return { success: false, message: "Internal server error" }; + }) + .get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() })) + .use(bugReportRoutes) + .use(adminRoutes) + .listen({ + port: config.port, + maxBody: 50 * 1024 * 1024, // 50MB + }); + +console.log( + `EMLy Bug Report API running on http://localhost:${app.server?.port}` +); + +// Graceful shutdown +process.on("SIGINT", async () => { + console.log("Shutting down..."); + await closePool(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("Shutting down..."); + await closePool(); + process.exit(0); +}); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..6c084b7 --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,24 @@ +import { Elysia } from "elysia"; +import { config } from "../config"; + +export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive( + { as: "scoped" }, + ({ headers, error }) => { + const key = headers["x-api-key"]; + if (!key || key !== config.apiKey) { + return error(401, { success: false, message: "Invalid or missing API key" }); + } + return {}; + } +); + +export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive( + { as: "scoped" }, + ({ headers, error }) => { + const key = headers["x-admin-key"]; + if (!key || key !== config.adminKey) { + return error(401, { success: false, message: "Invalid or missing admin key" }); + } + return {}; + } +); diff --git a/server/src/middleware/rateLimit.ts b/server/src/middleware/rateLimit.ts new file mode 100644 index 0000000..dfd3ded --- /dev/null +++ b/server/src/middleware/rateLimit.ts @@ -0,0 +1,70 @@ +import { Elysia } from "elysia"; +import { getPool } from "../db/connection"; +import { config } from "../config"; + +const excludedHwids = new Set([ + // Add HWIDs here for development testing + "95e025d1-7567-462e-9354-ac88b965cd22", +]); + +export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive( + { as: "scoped" }, + // @ts-ignore + async ({ body, error }) => { + const hwid = (body as { hwid?: string })?.hwid; + if (!hwid || excludedHwids.has(hwid)) { + // No HWID provided or excluded, skip rate limiting + return {}; + } + + const pool = getPool(); + const windowMs = config.rateLimit.windowHours * 60 * 60 * 1000; + const now = new Date(); + + // Get current rate limit entry + const [rows] = await pool.execute( + "SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?", + [hwid] + ); + + const entries = rows as { window_start: Date; count: number }[]; + + if (entries.length === 0) { + // First request from this HWID + await pool.execute( + "INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)", + [hwid, now] + ); + return {}; + } + + const entry = entries[0]; + const windowStart = new Date(entry.window_start); + const elapsed = now.getTime() - windowStart.getTime(); + + if (elapsed > windowMs) { + // Window expired, reset + await pool.execute( + "UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?", + [now, hwid] + ); + return {}; + } + + if (entry.count >= config.rateLimit.max) { + const retryAfterMs = windowMs - elapsed; + const retryAfterMin = Math.ceil(retryAfterMs / 60000); + return error(429, { + success: false, + message: `Rate limit exceeded. Try again in ${retryAfterMin} minutes.`, + }); + } + + // Increment count + await pool.execute( + "UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?", + [hwid] + ); + return {}; + } +); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts new file mode 100644 index 0000000..de42a98 --- /dev/null +++ b/server/src/routes/admin.ts @@ -0,0 +1,104 @@ +import { Elysia, t } from "elysia"; +import { adminKeyGuard } from "../middleware/auth"; +import { + listBugReports, + getBugReport, + getFile, + deleteBugReport, + updateBugReportStatus, +} from "../services/bugReportService"; +import type { BugReportStatus } from "../types"; + +export const adminRoutes = new Elysia({ prefix: "/api/admin" }) + .use(adminKeyGuard) + .get( + "/bug-reports", + async ({ query }) => { + const page = parseInt(query.page || "1"); + const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); + const status = query.status as BugReportStatus | undefined; + + return await listBugReports({ page, pageSize, status }); + }, + { + query: t.Object({ + page: t.Optional(t.String()), + pageSize: t.Optional(t.String()), + status: t.Optional( + t.Union([ + t.Literal("new"), + t.Literal("in_review"), + t.Literal("resolved"), + t.Literal("closed"), + ]) + ), + }), + detail: { summary: "List bug reports (paginated)" }, + } + ) + .get( + "/bug-reports/:id", + async ({ params, error }) => { + const result = await getBugReport(parseInt(params.id)); + if (!result) return error(404, { success: false, message: "Report not found" }); + return result; + }, + { + params: t.Object({ id: t.String() }), + detail: { summary: "Get bug report with file metadata" }, + } + ) + .patch( + "/bug-reports/:id/status", + async ({ params, body, error }) => { + const updated = await updateBugReportStatus( + parseInt(params.id), + body.status + ); + if (!updated) + return error(404, { success: false, message: "Report not found" }); + return { success: true, message: "Status updated" }; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + status: t.Union([ + t.Literal("new"), + t.Literal("in_review"), + t.Literal("resolved"), + t.Literal("closed"), + ]), + }), + detail: { summary: "Update bug report status" }, + } + ) + .get( + "/bug-reports/:id/files/:fileId", + async ({ params, error, set }) => { + const file = await getFile(parseInt(params.id), parseInt(params.fileId)); + if (!file) + return error(404, { success: false, message: "File not found" }); + + set.headers["content-type"] = file.mime_type; + set.headers["content-disposition"] = + `attachment; filename="${file.filename}"`; + return new Response(file.data); + }, + { + params: t.Object({ id: t.String(), fileId: t.String() }), + detail: { summary: "Download a bug report file" }, + } + ) + .delete( + "/bug-reports/:id", + async ({ params, error }) => { + const deleted = await deleteBugReport(parseInt(params.id)); + if (!deleted) + return error(404, { success: false, message: "Report not found" }); + return { success: true, message: "Report deleted" }; + }, + { + params: t.Object({ id: t.String() }), + detail: { summary: "Delete a bug report and its files" }, + } + ); diff --git a/server/src/routes/bugReports.ts b/server/src/routes/bugReports.ts new file mode 100644 index 0000000..8541705 --- /dev/null +++ b/server/src/routes/bugReports.ts @@ -0,0 +1,101 @@ +import { Elysia, t } from "elysia"; +import { apiKeyGuard } from "../middleware/auth"; +import { hwidRateLimit } from "../middleware/rateLimit"; +import { createBugReport, addFile } from "../services/bugReportService"; +import type { FileRole } from "../types"; + +const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ + { field: "screenshot", role: "screenshot", mime: "image/png" }, + { field: "mail_file", role: "mail_file", mime: "application/octet-stream" }, + { field: "localstorage", role: "localstorage", mime: "application/json" }, + { field: "config", role: "config", mime: "application/json" }, +]; + +export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) + .use(apiKeyGuard) + .use(hwidRateLimit) + .post( + "/", + async ({ body, request, set }) => { + const { name, email, description, hwid, hostname, os_user, system_info } = body; + + // Parse system_info — may arrive as a JSON string or already-parsed object + let systemInfo: Record | null = null; + if (system_info) { + if (typeof system_info === "string") { + try { + systemInfo = JSON.parse(system_info); + } catch { + systemInfo = null; + } + } else if (typeof system_info === "object") { + systemInfo = system_info as Record; + } + } + + // Get submitter IP from headers or connection + const submitterIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + + // Create the bug report + const reportId = await createBugReport({ + name, + email, + description, + hwid: hwid || "", + hostname: hostname || "", + os_user: os_user || "", + submitter_ip: submitterIp, + system_info: systemInfo, + }); + + // Process file uploads + for (const { field, role, mime } of FILE_ROLES) { + const file = body[field as keyof typeof body]; + if (file && file instanceof File) { + const buffer = Buffer.from(await file.arrayBuffer()); + await addFile({ + report_id: reportId, + file_role: role, + filename: file.name || `${field}.bin`, + mime_type: file.type || mime, + file_size: buffer.length, + data: buffer, + }); + } + } + + set.status = 201; + return { + success: true, + report_id: reportId, + message: "Bug report submitted successfully", + }; + }, + { + type: "multipart/form-data", + body: t.Object({ + name: t.String(), + email: t.String(), + description: t.String(), + hwid: t.Optional(t.String()), + hostname: t.Optional(t.String()), + os_user: t.Optional(t.String()), + system_info: t.Optional(t.Any()), + screenshot: t.Optional(t.File()), + mail_file: t.Optional(t.File()), + localstorage: t.Optional(t.File()), + config: t.Optional(t.File()), + }), + response: { + 201: t.Object({ + success: t.Boolean(), + report_id: t.Number(), + message: t.String(), + }), + }, + detail: { summary: "Submit a bug report" }, + } + ); diff --git a/server/src/services/bugReportService.ts b/server/src/services/bugReportService.ts new file mode 100644 index 0000000..6ab6056 --- /dev/null +++ b/server/src/services/bugReportService.ts @@ -0,0 +1,163 @@ +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import { getPool } from "../db/connection"; +import type { + BugReport, + BugReportFile, + BugReportListItem, + BugReportStatus, + FileRole, + PaginatedResponse, +} from "../types"; + +export async function createBugReport(data: { + name: string; + email: string; + description: string; + hwid: string; + hostname: string; + os_user: string; + submitter_ip: string; + system_info: Record | null; +}): Promise { + const pool = getPool(); + const [result] = await pool.execute( + `INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.name, + data.email, + data.description, + data.hwid, + data.hostname, + data.os_user, + data.submitter_ip, + data.system_info ? JSON.stringify(data.system_info) : null, + ] + ); + return result.insertId; +} + +export async function addFile(data: { + report_id: number; + file_role: FileRole; + filename: string; + mime_type: string; + file_size: number; + data: Buffer; +}): Promise { + const pool = getPool(); + const [result] = await pool.execute( + `INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + data.report_id, + data.file_role, + data.filename, + data.mime_type, + data.file_size, + data.data, + ] + ); + return result.insertId; +} + +export async function listBugReports(opts: { + page: number; + pageSize: number; + status?: BugReportStatus; +}): Promise> { + const pool = getPool(); + const { page, pageSize, status } = opts; + const offset = (page - 1) * pageSize; + + let whereClause = ""; + const params: unknown[] = []; + + if (status) { + whereClause = "WHERE br.status = ?"; + params.push(status); + } + + const [countRows] = await pool.execute( + `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`, + params + ); + const total = (countRows[0] as { total: number }).total; + + const [rows] = await pool.execute( + `SELECT br.*, COUNT(bf.id) as file_count + FROM bug_reports br + LEFT JOIN bug_report_files bf ON bf.report_id = br.id + ${whereClause} + GROUP BY br.id + ORDER BY br.created_at DESC + LIMIT ? OFFSET ?`, + [...params, pageSize, offset] + ); + + return { + data: rows as BugReportListItem[], + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; +} + +export async function getBugReport( + id: number +): Promise<{ report: BugReport; files: Omit[] } | null> { + const pool = getPool(); + + const [reportRows] = await pool.execute( + "SELECT * FROM bug_reports WHERE id = ?", + [id] + ); + + if ((reportRows as unknown[]).length === 0) return null; + + const [fileRows] = await pool.execute( + "SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?", + [id] + ); + + return { + report: reportRows[0] as BugReport, + files: fileRows as Omit[], + }; +} + +export async function getFile( + reportId: number, + fileId: number +): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?", + [fileId, reportId] + ); + + if ((rows as unknown[]).length === 0) return null; + return rows[0] as BugReportFile; +} + +export async function deleteBugReport(id: number): Promise { + const pool = getPool(); + const [result] = await pool.execute( + "DELETE FROM bug_reports WHERE id = ?", + [id] + ); + return result.affectedRows > 0; +} + +export async function updateBugReportStatus( + id: number, + status: BugReportStatus +): Promise { + const pool = getPool(); + const [result] = await pool.execute( + "UPDATE bug_reports SET status = ? WHERE id = ?", + [status, id] + ); + return result.affectedRows > 0; +} diff --git a/server/src/types/index.ts b/server/src/types/index.ts new file mode 100644 index 0000000..6b933a4 --- /dev/null +++ b/server/src/types/index.ts @@ -0,0 +1,57 @@ +export type BugReportStatus = "new" | "in_review" | "resolved" | "closed"; + +export type FileRole = + | "screenshot" + | "mail_file" + | "localstorage" + | "config" + | "system_info"; + +export interface BugReport { + id: number; + name: string; + email: string; + description: string; + hwid: string; + hostname: string; + os_user: string; + submitter_ip: string; + system_info: Record | null; + status: BugReportStatus; + created_at: Date; + updated_at: Date; +} + +export interface BugReportFile { + id: number; + report_id: number; + file_role: FileRole; + filename: string; + mime_type: string; + file_size: number; + data?: Buffer; + created_at: Date; +} + +export interface BugReportListItem { + id: number; + name: string; + email: string; + description: string; + hwid: string; + hostname: string; + os_user: string; + submitter_ip: string; + status: BugReportStatus; + created_at: Date; + updated_at: Date; + file_count: number; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..3575ba1 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "declaration": true, + "types": ["bun"] + }, + "include": ["src/**/*.ts"] +} -- 2.47.3 From c6c27f2f302267e3fecfff32ac2006a325f2a13e Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Sat, 14 Feb 2026 21:35:27 +0100 Subject: [PATCH 03/14] feat: initialize dashboard with bug reporting system - Add Tailwind CSS for styling and custom theme variables. - Create HTML structure for the dashboard with dark mode support. - Implement database schema for bug reports and associated files using Drizzle ORM. - Set up database connection with MySQL and environment variables. - Create utility functions for class names, byte formatting, and date formatting. - Develop error handling page for the dashboard. - Implement layout and routing for the dashboard, including pagination and filtering for bug reports. - Create API endpoints for downloading reports and files. - Add functionality for viewing, updating, and deleting bug reports. - Set up Docker configuration for the dashboard service. - Include TypeScript configuration and Vite setup for the project. --- DOCUMENTATION.md | 16 + dashboard/.env.example | 6 + dashboard/.gitignore | 5 + dashboard/Dockerfile | 9 + dashboard/drizzle.config.ts | 13 + dashboard/package.json | 34 +++ dashboard/src/app.css | 42 +++ dashboard/src/app.html | 12 + dashboard/src/lib/schema.ts | 54 ++++ dashboard/src/lib/server/db.ts | 16 + dashboard/src/lib/utils.ts | 39 +++ dashboard/src/routes/+error.svelte | 14 + dashboard/src/routes/+layout.server.ts | 15 + dashboard/src/routes/+layout.svelte | 61 ++++ dashboard/src/routes/+page.server.ts | 77 +++++ dashboard/src/routes/+page.svelte | 178 +++++++++++ .../api/reports/[id]/download/+server.ts | 68 +++++ .../reports/[id]/files/[fileId]/+server.ts | 28 ++ .../src/routes/reports/[id]/+page.server.ts | 44 +++ .../src/routes/reports/[id]/+page.svelte | 279 ++++++++++++++++++ dashboard/src/routes/reports/[id]/+server.ts | 39 +++ dashboard/svelte.config.js | 12 + dashboard/tsconfig.json | 14 + dashboard/vite.config.ts | 7 + server/docker-compose.yml | 14 + 25 files changed, 1096 insertions(+) create mode 100644 dashboard/.env.example create mode 100644 dashboard/.gitignore create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/drizzle.config.ts create mode 100644 dashboard/package.json create mode 100644 dashboard/src/app.css create mode 100644 dashboard/src/app.html create mode 100644 dashboard/src/lib/schema.ts create mode 100644 dashboard/src/lib/server/db.ts create mode 100644 dashboard/src/lib/utils.ts create mode 100644 dashboard/src/routes/+error.svelte create mode 100644 dashboard/src/routes/+layout.server.ts create mode 100644 dashboard/src/routes/+layout.svelte create mode 100644 dashboard/src/routes/+page.server.ts create mode 100644 dashboard/src/routes/+page.svelte create mode 100644 dashboard/src/routes/api/reports/[id]/download/+server.ts create mode 100644 dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts create mode 100644 dashboard/src/routes/reports/[id]/+page.server.ts create mode 100644 dashboard/src/routes/reports/[id]/+page.svelte create mode 100644 dashboard/src/routes/reports/[id]/+server.ts create mode 100644 dashboard/svelte.config.js create mode 100644 dashboard/tsconfig.json create mode 100644 dashboard/vite.config.ts diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index edc3e56..113e845 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -686,6 +686,22 @@ A separate API server (`server/` directory) receives bug reports: - **Rate limiting**: HWID-based, configurable (default 5 reports per 24h) - **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin) +#### Bug Report Dashboard + +A web dashboard (`dashboard/` directory) for browsing, triaging, and downloading bug reports: +- **Stack**: SvelteKit (Svelte 5) + TailwindCSS v4 + Drizzle ORM + Bun.js +- **Deployment**: Docker service in `server/docker-compose.yml`, port 3001 +- **Database**: Connects directly to the same MySQL database via Drizzle ORM (read/write) +- **Features**: + - Paginated reports list with status filter and search (hostname, user, name, email) + - Report detail view with metadata, description, system info (collapsible JSON), and file list + - Status management (new → in_review → resolved → closed) + - Inline screenshot preview for attached screenshots + - Individual file download and bulk ZIP download (all files + report metadata) + - Report deletion with confirmation dialog + - Dark mode UI matching EMLy's aesthetic +- **Development**: `cd dashboard && bun install && bun dev` (localhost:3001) + #### Configuration (config.ini) ```ini diff --git a/dashboard/.env.example b/dashboard/.env.example new file mode 100644 index 0000000..7e790cc --- /dev/null +++ b/dashboard/.env.example @@ -0,0 +1,6 @@ +# MySQL Connection +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=emly +MYSQL_PASSWORD=change_me_in_production +MYSQL_DATABASE=emly_bugreports diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..41068e2 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,5 @@ +node_modules +.svelte-kit +build +.env +bun.lock diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..1e67551 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,9 @@ +FROM oven/bun:alpine +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install +COPY . . +RUN bun run build +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["bun", "build/index.js"] diff --git a/dashboard/drizzle.config.ts b/dashboard/drizzle.config.ts new file mode 100644 index 0000000..2c0ea05 --- /dev/null +++ b/dashboard/drizzle.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/lib/schema.ts', + dialect: 'mysql', + dbCredentials: { + host: process.env.MYSQL_HOST || 'localhost', + port: Number(process.env.MYSQL_PORT) || 3306, + user: process.env.MYSQL_USER || 'emly', + password: process.env.MYSQL_PASSWORD || '', + database: process.env.MYSQL_DATABASE || 'emly_bugreports' + } +}); diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..bfe1dc1 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,34 @@ +{ + "name": "emly-dashboard", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev --port 3001", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.21.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^25.2.3", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "drizzle-orm": "^0.38.0", + "mysql2": "^3.11.0", + "bits-ui": "^1.0.0", + "clsx": "^2.1.0", + "tailwind-merge": "^3.0.0", + "tailwind-variants": "^0.3.0", + "jszip": "^3.10.0", + "lucide-svelte": "^0.469.0" + }, + "type": "module" +} diff --git a/dashboard/src/app.css b/dashboard/src/app.css new file mode 100644 index 0000000..2ac6589 --- /dev/null +++ b/dashboard/src/app.css @@ -0,0 +1,42 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --color-background: hsl(222.2 84% 4.9%); + --color-foreground: hsl(210 40% 98%); + --color-card: hsl(222.2 84% 4.9%); + --color-card-foreground: hsl(210 40% 98%); + --color-popover: hsl(222.2 84% 4.9%); + --color-popover-foreground: hsl(210 40% 98%); + --color-primary: hsl(217.2 91.2% 59.8%); + --color-primary-foreground: hsl(222.2 47.4% 11.2%); + --color-secondary: hsl(217.2 32.6% 17.5%); + --color-secondary-foreground: hsl(210 40% 98%); + --color-muted: hsl(217.2 32.6% 17.5%); + --color-muted-foreground: hsl(215 20.2% 65.1%); + --color-accent: hsl(217.2 32.6% 17.5%); + --color-accent-foreground: hsl(210 40% 98%); + --color-destructive: hsl(0 62.8% 30.6%); + --color-destructive-foreground: hsl(210 40% 98%); + --color-border: hsl(217.2 32.6% 17.5%); + --color-input: hsl(217.2 32.6% 17.5%); + --color-ring: hsl(224.3 76.3% 48%); + --radius: 0.5rem; + + --color-sidebar: hsl(222.2 84% 3.5%); + --color-sidebar-foreground: hsl(210 40% 98%); + --color-sidebar-border: hsl(217.2 32.6% 12%); + + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; +} + +* { + border-color: var(--color-border); +} + +body { + background-color: var(--color-background); + color: var(--color-foreground); + font-family: var(--font-sans); +} diff --git a/dashboard/src/app.html b/dashboard/src/app.html new file mode 100644 index 0000000..a2e03b2 --- /dev/null +++ b/dashboard/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/dashboard/src/lib/schema.ts b/dashboard/src/lib/schema.ts new file mode 100644 index 0000000..c6557b5 --- /dev/null +++ b/dashboard/src/lib/schema.ts @@ -0,0 +1,54 @@ +import { + mysqlTable, + int, + varchar, + text, + json, + mysqlEnum, + timestamp, + customType +} from 'drizzle-orm/mysql-core'; + +const longblob = customType<{ data: Buffer }>({ + dataType() { + return 'longblob'; + } +}); + +export const bugReports = mysqlTable('bug_reports', { + id: int('id').autoincrement().primaryKey(), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull(), + description: text('description').notNull(), + hwid: varchar('hwid', { length: 255 }).notNull().default(''), + hostname: varchar('hostname', { length: 255 }).notNull().default(''), + os_user: varchar('os_user', { length: 255 }).notNull().default(''), + submitter_ip: varchar('submitter_ip', { length: 45 }).notNull().default(''), + system_info: json('system_info'), + status: mysqlEnum('status', ['new', 'in_review', 'resolved', 'closed']).notNull().default('new'), + created_at: timestamp('created_at').notNull().defaultNow(), + updated_at: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const bugReportFiles = mysqlTable('bug_report_files', { + id: int('id').autoincrement().primaryKey(), + report_id: int('report_id') + .notNull() + .references(() => bugReports.id, { onDelete: 'cascade' }), + file_role: mysqlEnum('file_role', [ + 'screenshot', + 'mail_file', + 'localstorage', + 'config', + 'system_info' + ]).notNull(), + filename: varchar('filename', { length: 255 }).notNull(), + mime_type: varchar('mime_type', { length: 127 }).notNull().default('application/octet-stream'), + file_size: int('file_size').notNull().default(0), + data: longblob('data').notNull(), + created_at: timestamp('created_at').notNull().defaultNow() +}); + +export type BugReport = typeof bugReports.$inferSelect; +export type BugReportFile = typeof bugReportFiles.$inferSelect; +export type BugReportStatus = BugReport['status']; diff --git a/dashboard/src/lib/server/db.ts b/dashboard/src/lib/server/db.ts new file mode 100644 index 0000000..d93f0ea --- /dev/null +++ b/dashboard/src/lib/server/db.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import * as schema from '$lib/schema'; +import { env } from '$env/dynamic/private'; + +const pool = mysql.createPool({ + host: env.MYSQL_HOST || 'localhost', + port: Number(env.MYSQL_PORT) || 3306, + user: env.MYSQL_USER || 'emly', + password: env.MYSQL_PASSWORD, + database: env.MYSQL_DATABASE || 'emly_bugreports', + connectionLimit: 10, + idleTimeout: 60000 +}); + +export const db = drizzle(pool, { schema, mode: 'default' }); diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts new file mode 100644 index 0000000..10803a7 --- /dev/null +++ b/dashboard/src/lib/utils.ts @@ -0,0 +1,39 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function formatDate(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +export const statusColors: Record = { + new: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + in_review: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + resolved: 'bg-green-500/20 text-green-400 border-green-500/30', + closed: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30' +}; + +export const statusLabels: Record = { + new: 'New', + in_review: 'In Review', + resolved: 'Resolved', + closed: 'Closed' +}; diff --git a/dashboard/src/routes/+error.svelte b/dashboard/src/routes/+error.svelte new file mode 100644 index 0000000..024d2f9 --- /dev/null +++ b/dashboard/src/routes/+error.svelte @@ -0,0 +1,14 @@ + + +
+

{$page.status}

+

{$page.error?.message || 'Something went wrong'}

+ + Back to Reports + +
diff --git a/dashboard/src/routes/+layout.server.ts b/dashboard/src/routes/+layout.server.ts new file mode 100644 index 0000000..c3760d9 --- /dev/null +++ b/dashboard/src/routes/+layout.server.ts @@ -0,0 +1,15 @@ +import type { LayoutServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports } from '$lib/schema'; +import { eq, count } from 'drizzle-orm'; + +export const load: LayoutServerLoad = async () => { + const [result] = await db + .select({ count: count() }) + .from(bugReports) + .where(eq(bugReports.status, 'new')); + + return { + newCount: result.count + }; +}; diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte new file mode 100644 index 0000000..a97862d --- /dev/null +++ b/dashboard/src/routes/+layout.svelte @@ -0,0 +1,61 @@ + + +
+ + + + +
+ +
+

+ {#if $page.url.pathname === '/'} + Bug Reports + {:else if $page.url.pathname.startsWith('/reports/')} + Report Detail + {:else} + Dashboard + {/if} +

+ {#if data.newCount > 0} +
+ + {data.newCount} new {data.newCount === 1 ? 'report' : 'reports'} +
+ {/if} +
+ + +
+ {@render children()} +
+
+
diff --git a/dashboard/src/routes/+page.server.ts b/dashboard/src/routes/+page.server.ts new file mode 100644 index 0000000..5cb069d --- /dev/null +++ b/dashboard/src/routes/+page.server.ts @@ -0,0 +1,77 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq, like, or, count, sql, desc } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ url }) => { + const page = Math.max(1, Number(url.searchParams.get('page')) || 1); + const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20)); + const status = url.searchParams.get('status') || ''; + const search = url.searchParams.get('search') || ''; + + const conditions = []; + if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) { + conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed')); + } + if (search) { + const pattern = `%${search}%`; + conditions.push( + or( + like(bugReports.hostname, pattern), + like(bugReports.os_user, pattern), + like(bugReports.name, pattern), + like(bugReports.email, pattern) + )! + ); + } + + const where = conditions.length > 0 + ? conditions.length === 1 + ? conditions[0] + : sql`${conditions[0]} AND ${conditions[1]}` + : undefined; + + const [totalResult] = await db + .select({ count: count() }) + .from(bugReports) + .where(where); + + const total = totalResult.count; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const fileCountSubquery = db + .select({ + report_id: bugReportFiles.report_id, + file_count: count().as('file_count') + }) + .from(bugReportFiles) + .groupBy(bugReportFiles.report_id) + .as('fc'); + + const reports = await db + .select({ + id: bugReports.id, + name: bugReports.name, + email: bugReports.email, + hostname: bugReports.hostname, + os_user: bugReports.os_user, + status: bugReports.status, + created_at: bugReports.created_at, + file_count: sql`COALESCE(${fileCountSubquery.file_count}, 0)`.as('file_count') + }) + .from(bugReports) + .leftJoin(fileCountSubquery, eq(bugReports.id, fileCountSubquery.report_id)) + .where(where) + .orderBy(desc(bugReports.created_at)) + .limit(pageSize) + .offset((page - 1) * pageSize); + + return { + reports: reports.map((r) => ({ + ...r, + created_at: r.created_at.toISOString() + })), + pagination: { page, pageSize, total, totalPages }, + filters: { status, search } + }; +}; diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte new file mode 100644 index 0000000..03f58a4 --- /dev/null +++ b/dashboard/src/routes/+page.svelte @@ -0,0 +1,178 @@ + + +
+ +
+
+ + e.key === 'Enter' && applyFilters()} + class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + + {#if data.filters.search || data.filters.status} + + {/if} +
+ + +
+ + + + + + + + + + + + + + {#each data.reports as report (report.id)} + goto(`/reports/${report.id}`)} + > + + + + + + + + + {:else} + + + + {/each} + +
IDHostnameUserReporterStatusFilesCreated
#{report.id}{report.hostname || '—'}{report.os_user || '—'} +
{report.name}
+
{report.email}
+
+ + {statusLabels[report.status]} + + + {#if report.file_count > 0} + + + {report.file_count} + + {:else} + + {/if} + {formatDate(report.created_at)}
+ No reports found. +
+
+ + + {#if data.pagination.totalPages > 1} +
+

+ Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to {Math.min( + data.pagination.page * data.pagination.pageSize, + data.pagination.total + )} of {data.pagination.total} reports +

+
+ + {#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p} + {#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)} + + {:else if p === data.pagination.page - 2 || p === data.pagination.page + 2} + ... + {/if} + {/each} + +
+
+ {/if} +
diff --git a/dashboard/src/routes/api/reports/[id]/download/+server.ts b/dashboard/src/routes/api/reports/[id]/download/+server.ts new file mode 100644 index 0000000..51595c2 --- /dev/null +++ b/dashboard/src/routes/api/reports/[id]/download/+server.ts @@ -0,0 +1,68 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq } from 'drizzle-orm'; +import JSZip from 'jszip'; + +export const GET: RequestHandler = async ({ params }) => { + const id = Number(params.id); + if (isNaN(id)) throw error(400, 'Invalid report ID'); + + const [report] = await db + .select() + .from(bugReports) + .where(eq(bugReports.id, id)) + .limit(1); + + if (!report) throw error(404, 'Report not found'); + + const files = await db + .select() + .from(bugReportFiles) + .where(eq(bugReportFiles.report_id, id)); + + const zip = new JSZip(); + + // Add report metadata as text file + const reportText = [ + `Bug Report #${report.id}`, + `========================`, + ``, + `Name: ${report.name}`, + `Email: ${report.email}`, + `Hostname: ${report.hostname}`, + `OS User: ${report.os_user}`, + `HWID: ${report.hwid}`, + `IP: ${report.submitter_ip}`, + `Status: ${report.status}`, + `Created: ${report.created_at.toISOString()}`, + `Updated: ${report.updated_at.toISOString()}`, + ``, + `Description:`, + `------------`, + report.description, + ``, + ...(report.system_info + ? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)] + : []) + ].join('\n'); + + zip.file('report.txt', reportText); + + // Add all files + for (const file of files) { + const folder = file.file_role; + zip.file(`${folder}/${file.filename}`, file.data); + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }); + + return new Response(new Uint8Array(zipBuffer), { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="report-${id}.zip"`, + 'Content-Length': String(zipBuffer.length) + } + }); +}; diff --git a/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts b/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts new file mode 100644 index 0000000..980bdfd --- /dev/null +++ b/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts @@ -0,0 +1,28 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { bugReportFiles } from '$lib/schema'; +import { eq, and } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ params }) => { + const reportId = Number(params.id); + const fileId = Number(params.fileId); + + if (isNaN(reportId) || isNaN(fileId)) throw error(400, 'Invalid ID'); + + const [file] = await db + .select() + .from(bugReportFiles) + .where(and(eq(bugReportFiles.id, fileId), eq(bugReportFiles.report_id, reportId))) + .limit(1); + + if (!file) throw error(404, 'File not found'); + + return new Response(new Uint8Array(file.data), { + headers: { + 'Content-Type': file.mime_type, + 'Content-Disposition': `inline; filename="${file.filename}"`, + 'Content-Length': String(file.file_size) + } + }); +}; diff --git a/dashboard/src/routes/reports/[id]/+page.server.ts b/dashboard/src/routes/reports/[id]/+page.server.ts new file mode 100644 index 0000000..83dea88 --- /dev/null +++ b/dashboard/src/routes/reports/[id]/+page.server.ts @@ -0,0 +1,44 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ params }) => { + const id = Number(params.id); + if (isNaN(id)) throw error(400, 'Invalid report ID'); + + const [report] = await db + .select() + .from(bugReports) + .where(eq(bugReports.id, id)) + .limit(1); + + if (!report) throw error(404, 'Report not found'); + + const files = await db + .select({ + id: bugReportFiles.id, + report_id: bugReportFiles.report_id, + file_role: bugReportFiles.file_role, + filename: bugReportFiles.filename, + mime_type: bugReportFiles.mime_type, + file_size: bugReportFiles.file_size, + created_at: bugReportFiles.created_at + }) + .from(bugReportFiles) + .where(eq(bugReportFiles.report_id, id)); + + return { + report: { + ...report, + system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null, + created_at: report.created_at.toISOString(), + updated_at: report.updated_at.toISOString() + }, + files: files.map((f) => ({ + ...f, + created_at: f.created_at.toISOString() + })) + }; +}; diff --git a/dashboard/src/routes/reports/[id]/+page.svelte b/dashboard/src/routes/reports/[id]/+page.svelte new file mode 100644 index 0000000..eb43c0a --- /dev/null +++ b/dashboard/src/routes/reports/[id]/+page.svelte @@ -0,0 +1,279 @@ + + +
+ + + + Back to Reports + + + +
+
+
+
+

Report #{data.report.id}

+ + {statusLabels[data.report.status]} + +
+

+ Submitted by {data.report.name} ({data.report.email}) +

+
+
+ + + + + + + ZIP + + + + +
+
+ + +
+
+

Hostname

+

{data.report.hostname || '—'}

+
+
+

OS User

+

{data.report.os_user || '—'}

+
+
+

HWID

+

{data.report.hwid || '—'}

+
+
+

IP Address

+

{data.report.submitter_ip || '—'}

+
+
+

Created

+

{formatDate(data.report.created_at)}

+
+
+

Updated

+

{formatDate(data.report.updated_at)}

+
+
+
+ + +
+

Description

+

{data.report.description}

+
+ + + {#if data.report.system_info} +
+ + {#if showSystemInfo} +
+
{data.report.system_info}
+
+ {/if} +
+ {/if} + + +
+

+ Attached Files ({data.files.length}) +

+ {#if data.files.length > 0} + + {@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')} + {#if screenshots.length > 0} +
+ {#each screenshots as file} +
+ {file.filename} +
{file.filename}
+
+ {/each} +
+ {/if} + +
+ + + + + + + + + + + {#each data.files as file} + {@const Icon = roleIcons[file.file_role] || FileText} + + + + + + + {/each} + +
RoleFilenameSizeAction
+ + + {roleLabels[file.file_role] || file.file_role} + + {file.filename}{formatBytes(file.file_size)} + + + Download + +
+
+ {:else} +

No files attached.

+ {/if} +
+
+ + +{#if showDeleteDialog} + +
e.key === 'Escape' && (showDeleteDialog = false)} + > + +
(showDeleteDialog = false)}>
+
+

Delete Report

+

+ Are you sure you want to delete report #{data.report.id}? This will permanently remove the + report and all attached files. This action cannot be undone. +

+
+ + +
+
+
+{/if} diff --git a/dashboard/src/routes/reports/[id]/+server.ts b/dashboard/src/routes/reports/[id]/+server.ts new file mode 100644 index 0000000..1b67138 --- /dev/null +++ b/dashboard/src/routes/reports/[id]/+server.ts @@ -0,0 +1,39 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports } from '$lib/schema'; +import { eq } from 'drizzle-orm'; + +export const PATCH: RequestHandler = async ({ params, request }) => { + const id = Number(params.id); + if (isNaN(id)) throw error(400, 'Invalid report ID'); + + const body = await request.json(); + const { status } = body; + + if (!['new', 'in_review', 'resolved', 'closed'].includes(status)) { + throw error(400, 'Invalid status'); + } + + const [result] = await db + .update(bugReports) + .set({ status }) + .where(eq(bugReports.id, id)); + + if (result.affectedRows === 0) throw error(404, 'Report not found'); + + return json({ success: true }); +}; + +export const DELETE: RequestHandler = async ({ params }) => { + const id = Number(params.id); + if (isNaN(id)) throw error(400, 'Invalid report ID'); + + const [result] = await db + .delete(bugReports) + .where(eq(bugReports.id, id)); + + if (result.affectedRows === 0) throw error(404, 'Report not found'); + + return json({ success: true }); +}; diff --git a/dashboard/svelte.config.js b/dashboard/svelte.config.js new file mode 100644 index 0000000..b4b7de8 --- /dev/null +++ b/dashboard/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000..bf699a8 --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +}); diff --git a/server/docker-compose.yml b/server/docker-compose.yml index ff3f75c..8514d85 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -36,5 +36,19 @@ services: mysql: condition: service_healthy + dashboard: + build: ../dashboard + ports: + - "${DASHBOARD_PORT:-3001}:3000" + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + depends_on: + mysql: + condition: service_healthy + volumes: mysql_data: -- 2.47.3 From c2052595cb339198bc0353cd7f4941c749d85d30 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Sat, 14 Feb 2026 22:58:23 +0100 Subject: [PATCH 04/14] feat: add refresh functionality for bug reports and improve pagination logic --- dashboard/src/routes/+page.server.ts | 61 ++++++++----------- dashboard/src/routes/+page.svelte | 19 +++++- .../src/routes/api/reports/refresh/+server.ts | 13 ++++ 3 files changed, 57 insertions(+), 36 deletions(-) create mode 100644 dashboard/src/routes/api/reports/refresh/+server.ts diff --git a/dashboard/src/routes/+page.server.ts b/dashboard/src/routes/+page.server.ts index 5cb069d..2f4c72f 100644 --- a/dashboard/src/routes/+page.server.ts +++ b/dashboard/src/routes/+page.server.ts @@ -1,7 +1,7 @@ import type { PageServerLoad } from './$types'; import { db } from '$lib/server/db'; import { bugReports, bugReportFiles } from '$lib/schema'; -import { eq, like, or, count, sql, desc } from 'drizzle-orm'; +import { eq, like, or, count, sql, desc, and } from 'drizzle-orm'; export const load: PageServerLoad = async ({ url }) => { const page = Math.max(1, Number(url.searchParams.get('page')) || 1); @@ -10,44 +10,31 @@ export const load: PageServerLoad = async ({ url }) => { const search = url.searchParams.get('search') || ''; const conditions = []; + if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) { conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed')); } + if (search) { - const pattern = `%${search}%`; conditions.push( or( - like(bugReports.hostname, pattern), - like(bugReports.os_user, pattern), - like(bugReports.name, pattern), - like(bugReports.email, pattern) - )! + like(bugReports.hostname, `%${search}%`), + like(bugReports.os_user, `%${search}%`), + like(bugReports.name, `%${search}%`), + like(bugReports.email, `%${search}%`) + ) ); } - const where = conditions.length > 0 - ? conditions.length === 1 - ? conditions[0] - : sql`${conditions[0]} AND ${conditions[1]}` - : undefined; + const where = conditions.length > 0 ? and(...conditions) : undefined; - const [totalResult] = await db - .select({ count: count() }) + // Get total count + const [{ total }] = await db + .select({ total: count() }) .from(bugReports) .where(where); - const total = totalResult.count; - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - - const fileCountSubquery = db - .select({ - report_id: bugReportFiles.report_id, - file_count: count().as('file_count') - }) - .from(bugReportFiles) - .groupBy(bugReportFiles.report_id) - .as('fc'); - + // Get paginated reports with file count const reports = await db .select({ id: bugReports.id, @@ -57,21 +44,27 @@ export const load: PageServerLoad = async ({ url }) => { os_user: bugReports.os_user, status: bugReports.status, created_at: bugReports.created_at, - file_count: sql`COALESCE(${fileCountSubquery.file_count}, 0)`.as('file_count') + file_count: count(bugReportFiles.id) }) .from(bugReports) - .leftJoin(fileCountSubquery, eq(bugReports.id, fileCountSubquery.report_id)) + .leftJoin(bugReportFiles, eq(bugReports.id, bugReportFiles.report_id)) .where(where) + .groupBy(bugReports.id) .orderBy(desc(bugReports.created_at)) .limit(pageSize) .offset((page - 1) * pageSize); return { - reports: reports.map((r) => ({ - ...r, - created_at: r.created_at.toISOString() - })), - pagination: { page, pageSize, total, totalPages }, - filters: { status, search } + reports, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize) + }, + filters: { + status, + search + } }; }; diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index 03f58a4..bbac0dc 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -1,8 +1,8 @@
@@ -66,6 +74,13 @@ Filter + {#if data.filters.search || data.filters.status} {#if data.filters.search || data.filters.status} +{/if} diff --git a/server/dashboard/src/lib/components/ui/button/index.ts b/server/dashboard/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/server/dashboard/src/lib/components/ui/card/card-action.svelte b/server/dashboard/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/card/card-content.svelte b/server/dashboard/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/card/card-description.svelte b/server/dashboard/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/server/dashboard/src/lib/components/ui/card/card-footer.svelte b/server/dashboard/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..2d4d0f2 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/card/card-header.svelte b/server/dashboard/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..2501788 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/card/card-title.svelte b/server/dashboard/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..7447231 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/card/card.svelte b/server/dashboard/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..99448cc --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/card/index.ts b/server/dashboard/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..840b2f6 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..5c6ee6d --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,45 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3845023 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..e7ff446 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..4e5c447 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..f81ad83 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte new file mode 100644 index 0000000..ccfa79c --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..e4d4b34 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..9d1e801 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog.svelte new file mode 100644 index 0000000..211672c --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/dialog.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/dialog/index.ts b/server/dashboard/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..076cef5 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,34 @@ +import Root from "./dialog.svelte"; +import Portal from "./dialog-portal.svelte"; +import Title from "./dialog-title.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Trigger from "./dialog-trigger.svelte"; +import Close from "./dialog-close.svelte"; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/server/dashboard/src/lib/components/ui/empty/empty-content.svelte b/server/dashboard/src/lib/components/ui/empty/empty-content.svelte new file mode 100644 index 0000000..f5a9c68 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/empty/empty-content.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-description.svelte b/server/dashboard/src/lib/components/ui/empty/empty-description.svelte new file mode 100644 index 0000000..85a866c --- /dev/null +++ b/server/dashboard/src/lib/components/ui/empty/empty-description.svelte @@ -0,0 +1,23 @@ + + +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-header.svelte b/server/dashboard/src/lib/components/ui/empty/empty-header.svelte new file mode 100644 index 0000000..296eaf8 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/empty/empty-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-media.svelte b/server/dashboard/src/lib/components/ui/empty/empty-media.svelte new file mode 100644 index 0000000..0b4e45d --- /dev/null +++ b/server/dashboard/src/lib/components/ui/empty/empty-media.svelte @@ -0,0 +1,41 @@ + + + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-title.svelte b/server/dashboard/src/lib/components/ui/empty/empty-title.svelte new file mode 100644 index 0000000..8c237aa --- /dev/null +++ b/server/dashboard/src/lib/components/ui/empty/empty-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/empty/empty.svelte b/server/dashboard/src/lib/components/ui/empty/empty.svelte new file mode 100644 index 0000000..4ccf060 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/empty/empty.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/empty/index.ts b/server/dashboard/src/lib/components/ui/empty/index.ts new file mode 100644 index 0000000..ae4c106 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/empty/index.ts @@ -0,0 +1,22 @@ +import Root from "./empty.svelte"; +import Header from "./empty-header.svelte"; +import Media from "./empty-media.svelte"; +import Title from "./empty-title.svelte"; +import Description from "./empty-description.svelte"; +import Content from "./empty-content.svelte"; + +export { + Root, + Header, + Media, + Title, + Description, + Content, + // + Root as Empty, + Header as EmptyHeader, + Media as EmptyMedia, + Title as EmptyTitle, + Description as EmptyDescription, + Content as EmptyContent, +}; diff --git a/server/dashboard/src/lib/components/ui/input/index.ts b/server/dashboard/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/server/dashboard/src/lib/components/ui/input/input.svelte b/server/dashboard/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..ff1a4c8 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/server/dashboard/src/lib/components/ui/label/index.ts b/server/dashboard/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/server/dashboard/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/server/dashboard/src/lib/components/ui/label/label.svelte b/server/dashboard/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d71afbc --- /dev/null +++ b/server/dashboard/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/select/index.ts b/server/dashboard/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..4dec358 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Root from "./select.svelte"; +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Portal from "./select-portal.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/server/dashboard/src/lib/components/ui/select/select-content.svelte b/server/dashboard/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..4b9ca43 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte b/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/select/select-group.svelte b/server/dashboard/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..a1f43bf --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/select/select-item.svelte b/server/dashboard/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..b85eef6 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/server/dashboard/src/lib/components/ui/select/select-label.svelte b/server/dashboard/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/select/select-portal.svelte b/server/dashboard/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..424bcdd --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte b/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte b/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/server/dashboard/src/lib/components/ui/select/select-separator.svelte b/server/dashboard/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/select/select-trigger.svelte b/server/dashboard/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..dbb81df --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/server/dashboard/src/lib/components/ui/select/select.svelte b/server/dashboard/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..05eb663 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/separator/index.ts b/server/dashboard/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/server/dashboard/src/lib/components/ui/separator/separator.svelte b/server/dashboard/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..f40999f --- /dev/null +++ b/server/dashboard/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/index.ts b/server/dashboard/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..28d7da1 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,34 @@ +import Root from "./sheet.svelte"; +import Portal from "./sheet-portal.svelte"; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..065fe04 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,60 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..333b17a --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dd9ed84 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..757a6a5 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..345e197 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte new file mode 100644 index 0000000..f3085a3 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9fda327 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet.svelte new file mode 100644 index 0000000..5bf9783 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sheet/sheet.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sidebar/constants.ts b/server/dashboard/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..4de4435 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts b/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..15248ad --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/server/dashboard/src/lib/components/ui/sidebar/index.ts b/server/dashboard/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..318a341 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..f121800 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..6259cb9 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..a76dfe1 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..415255f --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..b2e72b6 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..ec18a69 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..a1b2db1 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..19b3666 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..7d6d459 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..d3fe295 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..e8ecdb4 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..0acd1ec --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,103 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..4db4453 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..68604e2 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..c8cd4ff --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..681d0f1 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..76bd1d9 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..946ccce --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..5b0d0aa --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..704d54f --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..5a7deda --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..1825182 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..bac55d8 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/server/dashboard/src/lib/components/ui/skeleton/index.ts b/server/dashboard/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte b/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/server/dashboard/src/lib/components/ui/table/index.ts b/server/dashboard/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..14695c8 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/server/dashboard/src/lib/components/ui/table/table-body.svelte b/server/dashboard/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..29e9687 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/table/table-caption.svelte b/server/dashboard/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..4696cff --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/table/table-cell.svelte b/server/dashboard/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..2c0c26a --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/table/table-footer.svelte b/server/dashboard/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/table/table-head.svelte b/server/dashboard/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..b67a6f9 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/table/table-header.svelte b/server/dashboard/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/table/table-row.svelte b/server/dashboard/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..0df769e --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/server/dashboard/src/lib/components/ui/table/table.svelte b/server/dashboard/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..a334956 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
    + + {@render children?.()} +
    +
    diff --git a/server/dashboard/src/lib/components/ui/textarea/index.ts b/server/dashboard/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..ace797a --- /dev/null +++ b/server/dashboard/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from "./textarea.svelte"; + +export { + Root, + // + Root as Textarea, +}; diff --git a/server/dashboard/src/lib/components/ui/textarea/textarea.svelte b/server/dashboard/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..124e9d0 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/tooltip/index.ts b/server/dashboard/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..1718604 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Root from "./tooltip.svelte"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; +import Provider from "./tooltip-provider.svelte"; +import Portal from "./tooltip-portal.svelte"; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..2662522 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000..d234f7d --- /dev/null +++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000..8150bef --- /dev/null +++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000..0b0f9ce --- /dev/null +++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/server/dashboard/src/lib/hooks/is-mobile.svelte.ts b/server/dashboard/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..4829c00 --- /dev/null +++ b/server/dashboard/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from "svelte/reactivity"; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/server/dashboard/src/lib/schema.ts b/server/dashboard/src/lib/schema.ts index c6557b5..9d946eb 100644 --- a/server/dashboard/src/lib/schema.ts +++ b/server/dashboard/src/lib/schema.ts @@ -6,6 +6,7 @@ import { json, mysqlEnum, timestamp, + datetime, customType } from 'drizzle-orm/mysql-core'; @@ -49,6 +50,23 @@ export const bugReportFiles = mysqlTable('bug_report_files', { created_at: timestamp('created_at').notNull().defaultNow() }); +export const userTable = mysqlTable('user', { + id: varchar('id', { length: 255 }).primaryKey(), + username: varchar('username', { length: 255 }).notNull().unique(), + displayname: varchar('displayname', { length: 255 }).notNull().default(''), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + role: mysqlEnum('role', ['admin', 'user']).notNull().default('user'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const sessionTable = mysqlTable('session', { + id: varchar('id', { length: 255 }).primaryKey(), + userId: varchar('user_id', { length: 255 }) + .notNull() + .references(() => userTable.id), + expiresAt: datetime('expires_at').notNull() +}); + export type BugReport = typeof bugReports.$inferSelect; export type BugReportFile = typeof bugReportFiles.$inferSelect; export type BugReportStatus = BugReport['status']; diff --git a/server/dashboard/src/lib/server/auth.ts b/server/dashboard/src/lib/server/auth.ts new file mode 100644 index 0000000..590e6b0 --- /dev/null +++ b/server/dashboard/src/lib/server/auth.ts @@ -0,0 +1,36 @@ +import { Lucia } from 'lucia'; +import { DrizzleMySQLAdapter } from '@lucia-auth/adapter-drizzle'; +import { db } from './db'; +import { sessionTable, userTable } from '$lib/schema'; +import { dev } from '$app/environment'; + +const adapter = new DrizzleMySQLAdapter(db, sessionTable, userTable); + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !dev + } + }, + getUserAttributes: (attributes) => { + return { + username: attributes.username, + role: attributes.role, + displayname: attributes.displayname + }; + } +}); + +declare module 'lucia' { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; + role: 'admin' | 'user'; + displayname: string; +} +// End of file diff --git a/server/dashboard/src/lib/utils.ts b/server/dashboard/src/lib/utils.ts index 10803a7..3cebf9a 100644 --- a/server/dashboard/src/lib/utils.ts +++ b/server/dashboard/src/lib/utils.ts @@ -1,5 +1,5 @@ -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -37,3 +37,10 @@ export const statusLabels: Record = { resolved: 'Resolved', closed: 'Closed' }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/server/dashboard/src/routes/+layout.server.ts b/server/dashboard/src/routes/+layout.server.ts index c3760d9..d95b760 100644 --- a/server/dashboard/src/routes/+layout.server.ts +++ b/server/dashboard/src/routes/+layout.server.ts @@ -1,15 +1,25 @@ import type { LayoutServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { bugReports } from '$lib/schema'; import { eq, count } from 'drizzle-orm'; -export const load: LayoutServerLoad = async () => { +export const load: LayoutServerLoad = async ({ locals, url }) => { + if (url.pathname === '/login') { + return { newCount: 0, user: null }; + } + + if (!locals.user) { + redirect(302, '/login'); + } + const [result] = await db .select({ count: count() }) .from(bugReports) .where(eq(bugReports.status, 'new')); return { - newCount: result.count + newCount: result.count, + user: locals.user }; }; diff --git a/server/dashboard/src/routes/+layout.svelte b/server/dashboard/src/routes/+layout.svelte index a97862d..56ada1d 100644 --- a/server/dashboard/src/routes/+layout.svelte +++ b/server/dashboard/src/routes/+layout.svelte @@ -1,61 +1,121 @@ -
    - - - - -
    - -
    -

    - {#if $page.url.pathname === '/'} - Bug Reports - {:else if $page.url.pathname.startsWith('/reports/')} - Report Detail - {:else} - Dashboard - {/if} -

    - {#if data.newCount > 0} -
    - - {data.newCount} new {data.newCount === 1 ? 'report' : 'reports'} +{#if !data.user} + {@render children()} +{:else} + + + +
    + + EMLy Dashboard
    - {/if} -
    - - -
    - {@render children()} -
    -
    -
    + + + + Menu + + + + + {#snippet child({ props })} + + + Reports + + {/snippet} + + + {#if data.user.role === 'admin'} + + + {#snippet child({ props })} + + + Users + + {/snippet} + + + {/if} + + + + + + + + + {#snippet child({ props })} +
    +
    + {data.user.displayname || data.user.username} + + {data.user.role} + +
    +
    + +
    +
    + {/snippet} +
    +
    +
    +
    + + +
    +
    + + +

    + {#if $page.url.pathname === '/'} + Bug Reports + {:else if $page.url.pathname.startsWith('/reports/')} + Report Detail + {:else if $page.url.pathname === '/users'} + User Management + {:else} + Dashboard + {/if} +

    +
    +
    + {#if data.newCount > 0} +
    + + {data.newCount} new {data.newCount === 1 ? 'report' : 'reports'} +
    + {/if} +
    +
    +
    + {@render children()} +
    +
    + +{/if} diff --git a/server/dashboard/src/routes/+page.svelte b/server/dashboard/src/routes/+page.svelte index 44d914b..84b8900 100644 --- a/server/dashboard/src/routes/+page.svelte +++ b/server/dashboard/src/routes/+page.svelte @@ -2,13 +2,25 @@ import { goto, invalidateAll } from '$app/navigation'; import { page } from '$app/stores'; import { statusColors, statusLabels, formatDate } from '$lib/utils'; - import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw } from 'lucide-svelte'; + import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw, Inbox } from 'lucide-svelte'; + import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; + import * as Table from '$lib/components/ui/table'; + import * as Select from '$lib/components/ui/select'; + import * as Empty from '$lib/components/ui/empty'; let { data } = $props(); let searchInput = $state(''); let statusFilter = $state(''); + const statusOptions = [ + { value: 'new', label: 'New' }, + { value: 'in_review', label: 'In Review' }, + { value: 'resolved', label: 'Resolved' }, + { value: 'closed', label: 'Closed' } + ]; + $effect(() => { searchInput = data.filters.search; statusFilter = data.filters.status; @@ -48,7 +60,7 @@
    -
    - - - + + {#if data.filters.search || data.filters.status} - + {/if}
    -
    - - - - - - - - - - - - - - {#each data.reports as report (report.id)} - goto(`/reports/${report.id}`)} - > - - - - - - - - + {#if data.reports.length === 0} +
    + + + + + + No reports found + + There are no bug reports matching your current filters. + + + {#if data.filters.search || data.filters.status} + + + {:else} -
    - - - {/each} - -
    IDHostnameUserReporterStatusFilesCreated
    #{report.id}{report.hostname || '—'}{report.os_user || '—'} -
    {report.name}
    -
    {report.email}
    -
    - - {statusLabels[report.status]} - - - {#if report.file_count > 0} - - - {report.file_count} - - {:else} - - {/if} - {formatDate(report.created_at)}
    - No reports found. -
    -
    + + + + {/if} + +
    + {:else} +
    + + + + ID + Hostname + User + Reporter + Status + Files + Created + + + + {#each data.reports as report (report.id)} + goto(`/reports/${report.id}`)} + > + #{report.id} + {report.hostname || '—'} + {report.os_user || '—'} + +
    {report.name}
    +
    {report.email}
    +
    + + + {statusLabels[report.status]} + + + + {#if report.file_count > 0} + + + {report.file_count} + + {:else} + + {/if} + + + {formatDate(report.created_at)} + +
    + {/each} +
    +
    +
    + {/if} {#if data.pagination.totalPages > 1} @@ -158,35 +189,35 @@ )} of {data.pagination.total} reports

    - + {#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p} {#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)} - + {:else if p === data.pagination.page - 2 || p === data.pagination.page + 2} ... {/if} {/each} - +
    {/if} diff --git a/server/dashboard/src/routes/login/+page.server.ts b/server/dashboard/src/routes/login/+page.server.ts new file mode 100644 index 0000000..fc52c9c --- /dev/null +++ b/server/dashboard/src/routes/login/+page.server.ts @@ -0,0 +1,59 @@ +import type { Actions, PageServerLoad } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { verify } from '@node-rs/argon2'; +import { lucia } from '$lib/server/auth'; +import { db } from '$lib/server/db'; +import { userTable } from '$lib/schema'; +import { eq } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + redirect(302, '/'); + } +}; + +export const actions: Actions = { + default: async ({ request, cookies }) => { + const formData = await request.formData(); + const username = formData.get('username'); + const password = formData.get('password'); + + if (typeof username !== 'string' || typeof password !== 'string') { + return fail(400, { message: 'Invalid input' }); + } + + if (!username || !password) { + return fail(400, { message: 'Username and password are required' }); + } + + const [user] = await db + .select() + .from(userTable) + .where(eq(userTable.username, username)) + .limit(1); + + if (!user) { + return fail(400, { message: 'Invalid username or password' }); + } + + const validPassword = await verify(user.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); + + if (!validPassword) { + return fail(400, { message: 'Invalid username or password' }); + } + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + + redirect(302, '/'); + } +}; diff --git a/server/dashboard/src/routes/login/+page.svelte b/server/dashboard/src/routes/login/+page.svelte new file mode 100644 index 0000000..2c74fee --- /dev/null +++ b/server/dashboard/src/routes/login/+page.svelte @@ -0,0 +1,61 @@ + + +
    +
    +
    + +

    EMLy Dashboard

    +

    Sign in to continue

    +
    + + {#if form?.message} +
    + {form.message} +
    + {/if} + +
    +
    + + +
    + +
    + + +
    + + +
    +
    +
    diff --git a/server/dashboard/src/routes/logout/+page.server.ts b/server/dashboard/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..67b748e --- /dev/null +++ b/server/dashboard/src/routes/logout/+page.server.ts @@ -0,0 +1,24 @@ +import type { Actions, PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { lucia } from '$lib/server/auth'; + +export const load: PageServerLoad = async () => { + redirect(302, '/'); +}; + +export const actions: Actions = { + default: async ({ locals, cookies }) => { + if (!locals.session) { + redirect(302, '/login'); + } + + await lucia.invalidateSession(locals.session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + + redirect(302, '/login'); + } +}; diff --git a/server/dashboard/src/routes/reports/[id]/+page.svelte b/server/dashboard/src/routes/reports/[id]/+page.svelte index eb43c0a..9a9e713 100644 --- a/server/dashboard/src/routes/reports/[id]/+page.svelte +++ b/server/dashboard/src/routes/reports/[id]/+page.svelte @@ -10,14 +10,17 @@ Monitor, Settings, Database, - ChevronDown, - ChevronRight, Mail } from 'lucide-svelte'; + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import * as Card from '$lib/components/ui/card'; + import * as Select from '$lib/components/ui/select'; + import * as Table from '$lib/components/ui/table'; + import { Button } from '$lib/components/ui/button'; + import { Textarea } from '$lib/components/ui/textarea'; let { data } = $props(); let showDeleteDialog = $state(false); - let showSystemInfo = $state(false); let statusUpdating = $state(false); let deleting = $state(false); @@ -64,216 +67,213 @@
    - + -
    -
    -
    -
    -

    Report #{data.report.id}

    - - {statusLabels[data.report.status]} - + + +
    +
    +
    + Report #{data.report.id} + + {statusLabels[data.report.status]} + +
    + + Submitted by {data.report.name} ({data.report.email}) +
    -

    - Submitted by {data.report.name} ({data.report.email}) -

    -
    -
    - - +
    + + updateStatus(val)} + > + + {statusLabels[data.report.status]} + + + + + + + + - - - - ZIP - + + - - + + +
    -
    - - -
    -
    -

    Hostname

    -

    {data.report.hostname || '—'}

    + + + +
    +
    +

    Hostname

    +

    {data.report.hostname || '—'}

    +
    +
    +

    OS User

    +

    {data.report.os_user || '—'}

    +
    +
    +

    HWID

    +

    {data.report.hwid || '—'}

    +
    +
    +

    IP Address

    +

    {data.report.submitter_ip || '—'}

    +
    +
    +

    Created

    +

    {formatDate(data.report.created_at)}

    +
    +
    +

    Updated

    +

    {formatDate(data.report.updated_at)}

    +
    -
    -

    OS User

    -

    {data.report.os_user || '—'}

    -
    -
    -

    HWID

    -

    {data.report.hwid || '—'}

    -
    -
    -

    IP Address

    -

    {data.report.submitter_ip || '—'}

    -
    -
    -

    Created

    -

    {formatDate(data.report.created_at)}

    -
    -
    -

    Updated

    -

    {formatDate(data.report.updated_at)}

    -
    -
    -
    + + -
    -

    Description

    -

    {data.report.description}

    -
    + + + Description + + +

    {data.report.description}

    +
    +
    {#if data.report.system_info} -
    - - {#if showSystemInfo} -
    -
    {data.report.system_info}
    + + + System Information + + +
    +