Compare commits
7 Commits
webview-re
...
40340ce32a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40340ce32a | ||
|
|
0aaa026429 | ||
|
|
492db8fcf8 | ||
|
|
c2052595cb | ||
|
|
c6c27f2f30 | ||
|
|
d510c24b69 | ||
|
|
54a3dff1c2 |
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,7 +252,8 @@ The Go backend is split into logical files:
|
|||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `CreateBugReportFolder()` | Creates folder with screenshot and mail file |
|
| `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`)**
|
**Settings (`app_settings.go`)**
|
||||||
|
|
||||||
@@ -672,7 +673,42 @@ Complete bug reporting system:
|
|||||||
3. Includes current mail file if loaded
|
3. Includes current mail file if loaded
|
||||||
4. Gathers system information
|
4. Gathers system information
|
||||||
5. Creates ZIP archive in temp folder
|
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)
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
[EMLy]
|
||||||
|
BUGREPORT_API_URL="https://your-server.example.com"
|
||||||
|
BUGREPORT_API_KEY="your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
### 5. Settings Management
|
### 5. Settings Management
|
||||||
|
|
||||||
|
|||||||
166
app_bugreport.go
166
app_bugreport.go
@@ -5,8 +5,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -50,6 +55,12 @@ type SubmitBugReportResult struct {
|
|||||||
ZipPath string `json:"zipPath"`
|
ZipPath string `json:"zipPath"`
|
||||||
// FolderPath is the path to the bug report folder
|
// FolderPath is the path to the bug report folder
|
||||||
FolderPath string `json:"folderPath"`
|
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 nil, fmt.Errorf("failed to create zip file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SubmitBugReportResult{
|
result := &SubmitBugReportResult{
|
||||||
ZipPath: zipPath,
|
ZipPath: zipPath,
|
||||||
FolderPath: bugReportFolder,
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
41
app_mail.go
41
app_mail.go
@@ -73,6 +73,47 @@ func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
|
|||||||
return internal.ReadMsgFile(filePath)
|
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.
|
// ShowOpenFileDialog displays the system file picker dialog filtered for email files.
|
||||||
// This allows users to browse and select .eml or .msg files to open.
|
// This allows users to browse and select .eml or .msg files to open.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type EMLyConfig struct {
|
|||||||
UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"`
|
UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"`
|
||||||
UpdatePath string `ini:"UPDATE_PATH"`
|
UpdatePath string `ini:"UPDATE_PATH"`
|
||||||
UpdateAutoCheck string `ini:"UPDATE_AUTO_CHECK"`
|
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
|
// LoadConfig reads the config.ini file at the given path and returns a Config struct
|
||||||
|
|||||||
@@ -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
|
isPec := hasDatiCert && hasSmime
|
||||||
|
|
||||||
// Format From
|
// 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
|
isPec := hasDatiCert && hasSmime
|
||||||
|
|
||||||
// Format From
|
// Format From
|
||||||
|
|||||||
47
backend/utils/mail/format_detector.go
Normal file
47
backend/utils/mail/format_detector.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
58
backend/utils/mail/tnef_diag2_test.go
Normal file
58
backend/utils/mail/tnef_diag2_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backend/utils/mail/tnef_diag3_test.go
Normal file
67
backend/utils/mail/tnef_diag3_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/utils/mail/tnef_diag4_test.go
Normal file
78
backend/utils/mail/tnef_diag4_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
backend/utils/mail/tnef_diag5_test.go
Normal file
241
backend/utils/mail/tnef_diag5_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
backend/utils/mail/tnef_diag6_test.go
Normal file
209
backend/utils/mail/tnef_diag6_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
273
backend/utils/mail/tnef_diag7_test.go
Normal file
273
backend/utils/mail/tnef_diag7_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
97
backend/utils/mail/tnef_diag8_test.go
Normal file
97
backend/utils/mail/tnef_diag8_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/utils/mail/tnef_diag_test.go
Normal file
79
backend/utils/mail/tnef_diag_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
444
backend/utils/mail/tnef_reader.go
Normal file
444
backend/utils/mail/tnef_reader.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
59
backend/utils/mail/tnef_reader_test.go
Normal file
59
backend/utils/mail/tnef_reader_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@ LANGUAGE = it
|
|||||||
UPDATE_CHECK_ENABLED = false
|
UPDATE_CHECK_ENABLED = false
|
||||||
UPDATE_PATH =
|
UPDATE_PATH =
|
||||||
UPDATE_AUTO_CHECK = true
|
UPDATE_AUTO_CHECK = true
|
||||||
|
BUGREPORT_API_URL = "http://localhost:3000"
|
||||||
|
BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"
|
||||||
@@ -5,7 +5,10 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "^4.57.1",
|
||||||
|
"@types/html2canvas": "^1.0.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"pdfjs-dist": "^5.4.624",
|
"pdfjs-dist": "^5.4.624",
|
||||||
"svelte-flags": "^3.0.1",
|
"svelte-flags": "^3.0.1",
|
||||||
"svelte-sonner": "^1.0.7",
|
"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-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=="],
|
"@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/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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
@@ -433,6 +446,8 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
|
||||||
|
|||||||
@@ -218,5 +218,8 @@
|
|||||||
"pdf_error_no_data_desc": "No PDF data provided. Please open this window from the main EMLy application.",
|
"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_timeout": "Timeout loading PDF. The worker might have failed to initialize.",
|
||||||
"pdf_error_parsing": "Error parsing PDF: ",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,8 @@
|
|||||||
"pdf_error_rendering": "Errore nel rendering della pagina: ",
|
"pdf_error_rendering": "Errore nel rendering della pagina: ",
|
||||||
"mail_download_btn_label": "Scarica",
|
"mail_download_btn_label": "Scarica",
|
||||||
"mail_download_btn_title": "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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3c4a64d0cfb34e86fac16fceae842e43
|
1697d40a08e09716b8c29ddebeabd1ad
|
||||||
@@ -6,16 +6,24 @@
|
|||||||
import { Input } from "$lib/components/ui/input/index.js";
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
import { Label } from "$lib/components/ui/label/index.js";
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
import { Textarea } from "$lib/components/ui/textarea/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 { toast } from "svelte-sonner";
|
||||||
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
|
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { dev } from "$app/environment";
|
||||||
|
|
||||||
// Bug report form state
|
// Bug report form state
|
||||||
let userName = $state("");
|
let userName = $state("");
|
||||||
let userEmail = $state("");
|
let userEmail = $state("");
|
||||||
let bugDescription = $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
|
// Bug report screenshot state
|
||||||
let screenshotData = $state("");
|
let screenshotData = $state("");
|
||||||
let isCapturing = $state(false);
|
let isCapturing = $state(false);
|
||||||
@@ -28,6 +36,9 @@
|
|||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let isSuccess = $state(false);
|
let isSuccess = $state(false);
|
||||||
let resultZipPath = $state("");
|
let resultZipPath = $state("");
|
||||||
|
let uploadedToServer = $state(false);
|
||||||
|
let serverReportId = $state(0);
|
||||||
|
let uploadError = $state("");
|
||||||
let canSubmit: boolean = $derived(
|
let canSubmit: boolean = $derived(
|
||||||
bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing
|
bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing
|
||||||
);
|
);
|
||||||
@@ -100,6 +111,9 @@
|
|||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
isSuccess = false;
|
isSuccess = false;
|
||||||
resultZipPath = "";
|
resultZipPath = "";
|
||||||
|
uploadedToServer = false;
|
||||||
|
serverReportId = 0;
|
||||||
|
uploadError = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBugReportSubmit(event: Event) {
|
async function handleBugReportSubmit(event: Event) {
|
||||||
@@ -123,8 +137,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
resultZipPath = result.zipPath;
|
resultZipPath = result.zipPath;
|
||||||
|
uploadedToServer = result.uploaded;
|
||||||
|
serverReportId = result.reportId;
|
||||||
|
uploadError = result.uploadError;
|
||||||
isSuccess = true;
|
isSuccess = true;
|
||||||
console.log("Bug report created:", result.zipPath);
|
console.log("Bug report created:", result.zipPath, "uploaded:", result.uploaded);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create bug report:", err);
|
console.error("Failed to create bug report:", err);
|
||||||
toast.error(m.bugreport_error());
|
toast.error(m.bugreport_error());
|
||||||
@@ -162,15 +179,31 @@
|
|||||||
<!-- Success State -->
|
<!-- Success State -->
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
<CheckCircle class="h-5 w-5 text-green-500" />
|
{#if uploadedToServer}
|
||||||
{m.bugreport_success_title()}
|
<CloudUpload class="h-5 w-5 text-green-500" />
|
||||||
|
{m.bugreport_uploaded_title()}
|
||||||
|
{:else}
|
||||||
|
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||||
|
{m.bugreport_success_title()}
|
||||||
|
{/if}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
{m.bugreport_success_message()}
|
{#if uploadedToServer}
|
||||||
|
{m.bugreport_uploaded_success({ reportId: String(serverReportId) })}
|
||||||
|
{:else}
|
||||||
|
{m.bugreport_success_message()}
|
||||||
|
{/if}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="grid gap-4 py-4">
|
<div class="grid gap-4 py-4">
|
||||||
|
{#if uploadError}
|
||||||
|
<div class="flex items-start gap-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md p-3">
|
||||||
|
<AlertTriangle class="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
|
||||||
|
<p class="text-sm text-yellow-600 dark:text-yellow-400">{m.bugreport_upload_failed()}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="bg-muted rounded-md p-3">
|
<div class="bg-muted rounded-md p-3">
|
||||||
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
ReadEML,
|
ReadEML,
|
||||||
ReadMSG,
|
ReadMSG,
|
||||||
ReadPEC,
|
ReadPEC,
|
||||||
|
ReadAuto,
|
||||||
|
DetectEmailFormat,
|
||||||
ShowOpenFileDialog,
|
ShowOpenFileDialog,
|
||||||
SetCurrentMailFilePath,
|
SetCurrentMailFilePath,
|
||||||
ConvertToUTF8,
|
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 {
|
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
|
||||||
const lowerPath = filePath.toLowerCase();
|
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 {
|
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
|
* @param filePath - Path to the email file
|
||||||
* @returns LoadEmailResult with the email data or error
|
* @returns LoadEmailResult with the email data or error
|
||||||
*/
|
*/
|
||||||
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
|
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
|
||||||
|
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<LoadEmailResult> {
|
||||||
const fileType = getEmailFileType(filePath);
|
const fileType = getEmailFileType(filePath);
|
||||||
|
|
||||||
if (!fileType) {
|
if (!fileType) {
|
||||||
@@ -60,7 +102,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
|||||||
if (fileType === 'msg') {
|
if (fileType === 'msg') {
|
||||||
email = await ReadMSG(filePath, true);
|
email = await ReadMSG(filePath, true);
|
||||||
} else {
|
} else {
|
||||||
// Try PEC first, fall back to regular EML
|
|
||||||
try {
|
try {
|
||||||
email = await ReadPEC(filePath);
|
email = await ReadPEC(filePath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -68,7 +109,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process body if needed (decode base64)
|
|
||||||
if (email?.body) {
|
if (email?.body) {
|
||||||
const trimmed = email.body.trim();
|
const trimmed = email.body.trim();
|
||||||
if (looksLikeBase64(trimmed)) {
|
if (looksLikeBase64(trimmed)) {
|
||||||
@@ -79,18 +119,11 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { success: true, email, filePath };
|
||||||
success: true,
|
|
||||||
email,
|
|
||||||
filePath,
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
console.error('Failed to load email:', error);
|
console.error('Failed to load email:', error);
|
||||||
return {
|
return { success: false, error: errorMessage };
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export {
|
|||||||
getEmailFileType,
|
getEmailFileType,
|
||||||
isEmailFile,
|
isEmailFile,
|
||||||
loadEmailFromPath,
|
loadEmailFromPath,
|
||||||
|
loadEmailFromPathLegacy,
|
||||||
openAndLoadEmail,
|
openAndLoadEmail,
|
||||||
processEmailBody,
|
processEmailBody,
|
||||||
type LoadEmailResult,
|
type LoadEmailResult,
|
||||||
|
|||||||
44
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
44
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
@@ -1,15 +1,29 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {utils} from '../models';
|
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
|
import {utils} from '../models';
|
||||||
import {internal} from '../models';
|
import {internal} from '../models';
|
||||||
|
|
||||||
|
export function CheckForUpdates():Promise<main.UpdateStatus>;
|
||||||
|
|
||||||
export function CheckIsDefaultEMLHandler():Promise<boolean>;
|
export function CheckIsDefaultEMLHandler():Promise<boolean>;
|
||||||
|
|
||||||
|
export function ConvertToUTF8(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function CreateBugReportFolder():Promise<main.BugReportResult>;
|
||||||
|
|
||||||
|
export function DetectEmailFormat(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadUpdate():Promise<string>;
|
||||||
|
|
||||||
|
export function ExportSettings(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function FrontendLog(arg1:string,arg2:string):Promise<void>;
|
export function FrontendLog(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function GetConfig():Promise<utils.Config>;
|
export function GetConfig():Promise<utils.Config>;
|
||||||
|
|
||||||
|
export function GetCurrentMailFilePath():Promise<string>;
|
||||||
|
|
||||||
export function GetImageViewerData():Promise<main.ImageViewerData>;
|
export function GetImageViewerData():Promise<main.ImageViewerData>;
|
||||||
|
|
||||||
export function GetMachineData():Promise<utils.MachineInfo>;
|
export function GetMachineData():Promise<utils.MachineInfo>;
|
||||||
@@ -18,14 +32,26 @@ export function GetPDFViewerData():Promise<main.PDFViewerData>;
|
|||||||
|
|
||||||
export function GetStartupFile():Promise<string>;
|
export function GetStartupFile():Promise<string>;
|
||||||
|
|
||||||
|
export function GetUpdateStatus():Promise<main.UpdateStatus>;
|
||||||
|
|
||||||
export function GetViewerData():Promise<main.ViewerData>;
|
export function GetViewerData():Promise<main.ViewerData>;
|
||||||
|
|
||||||
|
export function ImportSettings():Promise<string>;
|
||||||
|
|
||||||
|
export function InstallUpdate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function InstallUpdateSilent():Promise<void>;
|
||||||
|
|
||||||
|
export function InstallUpdateSilentFromPath(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function IsDebuggerRunning():Promise<boolean>;
|
export function IsDebuggerRunning():Promise<boolean>;
|
||||||
|
|
||||||
export function OpenDefaultAppsSettings():Promise<void>;
|
export function OpenDefaultAppsSettings():Promise<void>;
|
||||||
|
|
||||||
export function OpenEMLWindow(arg1:string,arg2:string):Promise<void>;
|
export function OpenEMLWindow(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function OpenFolderInExplorer(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenImage(arg1:string,arg2:string):Promise<void>;
|
export function OpenImage(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenImageWindow(arg1:string,arg2:string):Promise<void>;
|
export function OpenImageWindow(arg1:string,arg2:string):Promise<void>;
|
||||||
@@ -34,8 +60,12 @@ export function OpenPDF(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function OpenPDFWindow(arg1:string,arg2:string):Promise<void>;
|
export function OpenPDFWindow(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function OpenURLInBrowser(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function ReadAuto(arg1:string):Promise<internal.EmailData>;
|
||||||
|
|
||||||
export function ReadEML(arg1:string):Promise<internal.EmailData>;
|
export function ReadEML(arg1:string):Promise<internal.EmailData>;
|
||||||
|
|
||||||
export function ReadMSG(arg1:string,arg2:boolean):Promise<internal.EmailData>;
|
export function ReadMSG(arg1:string,arg2:boolean):Promise<internal.EmailData>;
|
||||||
@@ -46,4 +76,16 @@ export function ReadPEC(arg1:string):Promise<internal.EmailData>;
|
|||||||
|
|
||||||
export function SaveConfig(arg1:utils.Config):Promise<void>;
|
export function SaveConfig(arg1:utils.Config):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveScreenshot():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveScreenshotAs():Promise<string>;
|
||||||
|
|
||||||
|
export function SetCurrentMailFilePath(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetUpdateCheckerEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function ShowOpenFileDialog():Promise<string>;
|
export function ShowOpenFileDialog():Promise<string>;
|
||||||
|
|
||||||
|
export function SubmitBugReport(arg1:main.BugReportInput):Promise<main.SubmitBugReportResult>;
|
||||||
|
|
||||||
|
export function TakeScreenshot():Promise<main.ScreenshotResult>;
|
||||||
|
|||||||
@@ -2,10 +2,34 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function CheckForUpdates() {
|
||||||
|
return window['go']['main']['App']['CheckForUpdates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CheckIsDefaultEMLHandler() {
|
export function CheckIsDefaultEMLHandler() {
|
||||||
return window['go']['main']['App']['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) {
|
export function FrontendLog(arg1, arg2) {
|
||||||
return window['go']['main']['App']['FrontendLog'](arg1, arg2);
|
return window['go']['main']['App']['FrontendLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -14,6 +38,10 @@ export function GetConfig() {
|
|||||||
return window['go']['main']['App']['GetConfig']();
|
return window['go']['main']['App']['GetConfig']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCurrentMailFilePath() {
|
||||||
|
return window['go']['main']['App']['GetCurrentMailFilePath']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetImageViewerData() {
|
export function GetImageViewerData() {
|
||||||
return window['go']['main']['App']['GetImageViewerData']();
|
return window['go']['main']['App']['GetImageViewerData']();
|
||||||
}
|
}
|
||||||
@@ -30,10 +58,30 @@ export function GetStartupFile() {
|
|||||||
return window['go']['main']['App']['GetStartupFile']();
|
return window['go']['main']['App']['GetStartupFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetUpdateStatus() {
|
||||||
|
return window['go']['main']['App']['GetUpdateStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetViewerData() {
|
export function GetViewerData() {
|
||||||
return window['go']['main']['App']['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() {
|
export function IsDebuggerRunning() {
|
||||||
return window['go']['main']['App']['IsDebuggerRunning']();
|
return window['go']['main']['App']['IsDebuggerRunning']();
|
||||||
}
|
}
|
||||||
@@ -46,6 +94,10 @@ export function OpenEMLWindow(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function OpenImage(arg1, arg2) {
|
||||||
return window['go']['main']['App']['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);
|
return window['go']['main']['App']['OpenPDFWindow'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OpenURLInBrowser(arg1) {
|
||||||
|
return window['go']['main']['App']['OpenURLInBrowser'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function QuitApp() {
|
export function QuitApp() {
|
||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReadAuto(arg1) {
|
||||||
|
return window['go']['main']['App']['ReadAuto'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ReadEML(arg1) {
|
export function ReadEML(arg1) {
|
||||||
return window['go']['main']['App']['ReadEML'](arg1);
|
return window['go']['main']['App']['ReadEML'](arg1);
|
||||||
}
|
}
|
||||||
@@ -86,6 +146,30 @@ export function SaveConfig(arg1) {
|
|||||||
return window['go']['main']['App']['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() {
|
export function ShowOpenFileDialog() {
|
||||||
return window['go']['main']['App']['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']();
|
||||||
|
}
|
||||||
|
|||||||
@@ -242,6 +242,44 @@ export namespace internal {
|
|||||||
|
|
||||||
export namespace main {
|
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 {
|
export class ImageViewerData {
|
||||||
data: string;
|
data: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -270,6 +308,70 @@ export namespace main {
|
|||||||
this.filename = source["filename"];
|
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 {
|
export class ViewerData {
|
||||||
imageData?: ImageViewerData;
|
imageData?: ImageViewerData;
|
||||||
pdfData?: PDFViewerData;
|
pdfData?: PDFViewerData;
|
||||||
@@ -717,6 +819,10 @@ export namespace utils {
|
|||||||
SDKDecoderReleaseChannel: string;
|
SDKDecoderReleaseChannel: string;
|
||||||
GUISemver: string;
|
GUISemver: string;
|
||||||
GUIReleaseChannel: string;
|
GUIReleaseChannel: string;
|
||||||
|
Language: string;
|
||||||
|
UpdateCheckEnabled: string;
|
||||||
|
UpdatePath: string;
|
||||||
|
UpdateAutoCheck: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new EMLyConfig(source);
|
return new EMLyConfig(source);
|
||||||
@@ -728,6 +834,10 @@ export namespace utils {
|
|||||||
this.SDKDecoderReleaseChannel = source["SDKDecoderReleaseChannel"];
|
this.SDKDecoderReleaseChannel = source["SDKDecoderReleaseChannel"];
|
||||||
this.GUISemver = source["GUISemver"];
|
this.GUISemver = source["GUISemver"];
|
||||||
this.GUIReleaseChannel = source["GUIReleaseChannel"];
|
this.GUIReleaseChannel = source["GUIReleaseChannel"];
|
||||||
|
this.Language = source["Language"];
|
||||||
|
this.UpdateCheckEnabled = source["UpdateCheckEnabled"];
|
||||||
|
this.UpdatePath = source["UpdatePath"];
|
||||||
|
this.UpdateAutoCheck = source["UpdateAutoCheck"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -4,6 +4,7 @@ go 1.24.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jaypipes/ghw v0.21.2
|
github.com/jaypipes/ghw v0.21.2
|
||||||
|
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
golang.org/x/sys v0.40.0
|
golang.org/x/sys v0.40.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.22.0
|
||||||
@@ -30,6 +31,8 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.49.1 // 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/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
|||||||
9
go.sum
9
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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
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=
|
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/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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
|||||||
19
server/.env.example
Normal file
19
server/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
|
DASHBOARD_PORT=3001
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_MAX=5
|
||||||
|
RATE_LIMIT_WINDOW_HOURS=24
|
||||||
11
server/.gitignore
vendored
Normal file
11
server/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
dashboard/node_modules/
|
||||||
|
dashboard/.svelte-kit/
|
||||||
|
dashboard/build/
|
||||||
|
dashboard/.env
|
||||||
|
dashboard/bun.lock
|
||||||
13
server/Dockerfile
Normal file
13
server/Dockerfile
Normal file
@@ -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"]
|
||||||
6
server/dashboard/.env.example
Normal file
6
server/dashboard/.env.example
Normal file
@@ -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
|
||||||
5
server/dashboard/.gitignore
vendored
Normal file
5
server/dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
bun.lock
|
||||||
9
server/dashboard/Dockerfile
Normal file
9
server/dashboard/Dockerfile
Normal file
@@ -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"]
|
||||||
13
server/dashboard/drizzle.config.ts
Normal file
13
server/dashboard/drizzle.config.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
34
server/dashboard/package.json
Normal file
34
server/dashboard/package.json
Normal file
@@ -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.5.3",
|
||||||
|
"@sveltejs/kit": "^2.51.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"svelte": "^5.51.1",
|
||||||
|
"svelte-check": "^4.4.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.4.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"drizzle-orm": "^0.38.4",
|
||||||
|
"mysql2": "^3.17.1",
|
||||||
|
"bits-ui": "^1.8.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwind-variants": "^0.3.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"lucide-svelte": "^0.469.0"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
42
server/dashboard/src/app.css
Normal file
42
server/dashboard/src/app.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
12
server/dashboard/src/app.html
Normal file
12
server/dashboard/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
server/dashboard/src/lib/schema.ts
Normal file
54
server/dashboard/src/lib/schema.ts
Normal file
@@ -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'];
|
||||||
16
server/dashboard/src/lib/server/db.ts
Normal file
16
server/dashboard/src/lib/server/db.ts
Normal file
@@ -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' });
|
||||||
39
server/dashboard/src/lib/utils.ts
Normal file
39
server/dashboard/src/lib/utils.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
new: 'New',
|
||||||
|
in_review: 'In Review',
|
||||||
|
resolved: 'Resolved',
|
||||||
|
closed: 'Closed'
|
||||||
|
};
|
||||||
14
server/dashboard/src/routes/+error.svelte
Normal file
14
server/dashboard/src/routes/+error.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
|
<h1 class="text-6xl font-bold text-muted-foreground">{$page.status}</h1>
|
||||||
|
<p class="mt-4 text-lg text-muted-foreground">{$page.error?.message || 'Something went wrong'}</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-6 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Back to Reports
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
15
server/dashboard/src/routes/+layout.server.ts
Normal file
15
server/dashboard/src/routes/+layout.server.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
61
server/dashboard/src/routes/+layout.svelte
Normal file
61
server/dashboard/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { Bug, LayoutDashboard } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { children, data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="flex w-56 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
|
||||||
|
<div class="flex items-center gap-2 border-b border-sidebar-border px-4 py-4">
|
||||||
|
<Bug class="h-6 w-6 text-primary" />
|
||||||
|
<span class="text-lg font-semibold">EMLy Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-2 py-3">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors {$page.url
|
||||||
|
.pathname === '/'
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
<LayoutDashboard class="h-4 w-4" />
|
||||||
|
Reports
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-sidebar-border px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
EMLy Bug Reports
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<header
|
||||||
|
class="flex h-14 items-center justify-between border-b border-border bg-card px-6"
|
||||||
|
>
|
||||||
|
<h1 class="text-lg font-semibold">
|
||||||
|
{#if $page.url.pathname === '/'}
|
||||||
|
Bug Reports
|
||||||
|
{:else if $page.url.pathname.startsWith('/reports/')}
|
||||||
|
Report Detail
|
||||||
|
{:else}
|
||||||
|
Dashboard
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
{#if data.newCount > 0}
|
||||||
|
<div class="flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-1.5 text-sm text-blue-400">
|
||||||
|
<span class="inline-block h-2 w-2 rounded-full bg-blue-400"></span>
|
||||||
|
{data.newCount} new {data.newCount === 1 ? 'report' : 'reports'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page content -->
|
||||||
|
<main class="flex-1 overflow-auto p-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
70
server/dashboard/src/routes/+page.server.ts
Normal file
70
server/dashboard/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { bugReports, bugReportFiles } from '$lib/schema';
|
||||||
|
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);
|
||||||
|
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) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(bugReports.hostname, `%${search}%`),
|
||||||
|
like(bugReports.os_user, `%${search}%`),
|
||||||
|
like(bugReports.name, `%${search}%`),
|
||||||
|
like(bugReports.email, `%${search}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const [{ total }] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(bugReports)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
// Get paginated reports with file count
|
||||||
|
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: count(bugReportFiles.id)
|
||||||
|
})
|
||||||
|
.from(bugReports)
|
||||||
|
.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,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize)
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
status,
|
||||||
|
search
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
193
server/dashboard/src/routes/+page.svelte
Normal file
193
server/dashboard/src/routes/+page.svelte
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let searchInput = $state('');
|
||||||
|
let statusFilter = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
searchInput = data.filters.search;
|
||||||
|
statusFilter = data.filters.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (searchInput) params.set('search', searchInput);
|
||||||
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
|
params.set('page', '1');
|
||||||
|
goto(`/?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
const params = new URLSearchParams($page.url.searchParams);
|
||||||
|
params.set('page', String(p));
|
||||||
|
goto(`/?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
searchInput = '';
|
||||||
|
statusFilter = '';
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshReports() {
|
||||||
|
try {
|
||||||
|
await invalidateAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh reports:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative flex-1 min-w-50 max-w-sm">
|
||||||
|
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search hostname, user, name, email..."
|
||||||
|
bind:value={searchInput}
|
||||||
|
onkeydown={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
bind:value={statusFilter}
|
||||||
|
onchange={applyFilters}
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="in_review">In Review</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onclick={applyFilters}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Filter class="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={refreshReports}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<RefreshCcw class="h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
{#if data.filters.search || data.filters.status}
|
||||||
|
<button
|
||||||
|
onclick={clearFilters}
|
||||||
|
class="rounded-md border border-input px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border bg-muted/50">
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">ID</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Hostname</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Reporter</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Files</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.reports as report (report.id)}
|
||||||
|
<tr
|
||||||
|
class="border-b border-border transition-colors hover:bg-muted/30 cursor-pointer"
|
||||||
|
onclick={() => goto(`/reports/${report.id}`)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 font-mono text-muted-foreground">#{report.id}</td>
|
||||||
|
<td class="px-4 py-3">{report.hostname || '—'}</td>
|
||||||
|
<td class="px-4 py-3">{report.os_user || '—'}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div>{report.name}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{report.email}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full border px-2 py-0.5 text-xs font-medium {statusColors[report.status]}"
|
||||||
|
>
|
||||||
|
{statusLabels[report.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{#if report.file_count > 0}
|
||||||
|
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Paperclip class="h-3.5 w-3.5" />
|
||||||
|
{report.file_count}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-muted-foreground">{formatDate(report.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-12 text-center text-muted-foreground">
|
||||||
|
No reports found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if data.pagination.totalPages > 1}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
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
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={() => goToPage(data.pagination.page - 1)}
|
||||||
|
disabled={data.pagination.page <= 1}
|
||||||
|
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{#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)}
|
||||||
|
<button
|
||||||
|
onclick={() => goToPage(p)}
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm {p ===
|
||||||
|
data.pagination.page
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'border border-input hover:bg-accent'}"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
{:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
|
||||||
|
<span class="px-1 text-muted-foreground">...</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
onclick={() => goToPage(data.pagination.page + 1)}
|
||||||
|
disabled={data.pagination.page >= data.pagination.totalPages}
|
||||||
|
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
13
server/dashboard/src/routes/api/reports/refresh/+server.ts
Normal file
13
server/dashboard/src/routes/api/reports/refresh/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { bugReports } from '$lib/schema';
|
||||||
|
import { count } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const [{ total }] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(bugReports);
|
||||||
|
|
||||||
|
return json({ total });
|
||||||
|
};
|
||||||
44
server/dashboard/src/routes/reports/[id]/+page.server.ts
Normal file
44
server/dashboard/src/routes/reports/[id]/+page.server.ts
Normal file
@@ -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()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
279
server/dashboard/src/routes/reports/[id]/+page.svelte
Normal file
279
server/dashboard/src/routes/reports/[id]/+page.svelte
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
import { statusColors, statusLabels, formatDate, formatBytes } from '$lib/utils';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Monitor,
|
||||||
|
Settings,
|
||||||
|
Database,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Mail
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let showDeleteDialog = $state(false);
|
||||||
|
let showSystemInfo = $state(false);
|
||||||
|
let statusUpdating = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
const roleIcons: Record<string, typeof FileText> = {
|
||||||
|
screenshot: Image,
|
||||||
|
mail_file: Mail,
|
||||||
|
localstorage: Database,
|
||||||
|
config: Settings,
|
||||||
|
system_info: Monitor
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
screenshot: 'Screenshot',
|
||||||
|
mail_file: 'Mail File',
|
||||||
|
localstorage: 'Local Storage',
|
||||||
|
config: 'Config',
|
||||||
|
system_info: 'System Info'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateStatus(newStatus: string) {
|
||||||
|
statusUpdating = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/reports/${data.report.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
if (res.ok) await invalidateAll();
|
||||||
|
} finally {
|
||||||
|
statusUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReport() {
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/reports/${data.report.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) goto('/');
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Back button -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Back to Reports
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Header card -->
|
||||||
|
<div class="rounded-lg border border-border bg-card p-6">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-xl font-semibold">Report #{data.report.id}</h2>
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium {statusColors[data.report.status]}"
|
||||||
|
>
|
||||||
|
{statusLabels[data.report.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Submitted by {data.report.name} ({data.report.email})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Status selector -->
|
||||||
|
<select
|
||||||
|
value={data.report.status}
|
||||||
|
onchange={(e) => updateStatus(e.currentTarget.value)}
|
||||||
|
disabled={statusUpdating}
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="in_review">In Review</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Download ZIP -->
|
||||||
|
<a
|
||||||
|
href="/api/reports/{data.report.id}/download"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
ZIP
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
onclick={() => (showDeleteDialog = true)}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground bg-destructive hover:bg-destructive/80"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata grid -->
|
||||||
|
<div class="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Hostname</p>
|
||||||
|
<p class="mt-1 text-sm">{data.report.hostname || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">OS User</p>
|
||||||
|
<p class="mt-1 text-sm">{data.report.os_user || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">HWID</p>
|
||||||
|
<p class="mt-1 font-mono text-xs break-all">{data.report.hwid || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">IP Address</p>
|
||||||
|
<p class="mt-1 text-sm">{data.report.submitter_ip || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Created</p>
|
||||||
|
<p class="mt-1 text-sm">{formatDate(data.report.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Updated</p>
|
||||||
|
<p class="mt-1 text-sm">{formatDate(data.report.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="rounded-lg border border-border bg-card p-6">
|
||||||
|
<h3 class="text-sm font-medium text-muted-foreground">Description</h3>
|
||||||
|
<p class="mt-2 whitespace-pre-wrap text-sm">{data.report.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Info -->
|
||||||
|
{#if data.report.system_info}
|
||||||
|
<div class="rounded-lg border border-border bg-card">
|
||||||
|
<button
|
||||||
|
onclick={() => (showSystemInfo = !showSystemInfo)}
|
||||||
|
class="flex w-full items-center gap-2 px-6 py-4 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{#if showSystemInfo}
|
||||||
|
<ChevronDown class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
System Information
|
||||||
|
</button>
|
||||||
|
{#if showSystemInfo}
|
||||||
|
<div class="border-t border-border px-6 py-4">
|
||||||
|
<pre class="overflow-auto rounded-md bg-muted/50 p-4 text-xs">{data.report.system_info}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Files -->
|
||||||
|
<div class="rounded-lg border border-border bg-card p-6">
|
||||||
|
<h3 class="mb-4 text-sm font-medium text-muted-foreground">
|
||||||
|
Attached Files ({data.files.length})
|
||||||
|
</h3>
|
||||||
|
{#if data.files.length > 0}
|
||||||
|
<!-- Screenshot previews -->
|
||||||
|
{@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
|
||||||
|
{#if screenshots.length > 0}
|
||||||
|
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{#each screenshots as file}
|
||||||
|
<div class="overflow-hidden rounded-md border border-border">
|
||||||
|
<img
|
||||||
|
src="/api/reports/{data.report.id}/files/{file.id}"
|
||||||
|
alt={file.filename}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="px-3 py-2 text-xs text-muted-foreground">{file.filename}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-md border border-border">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border bg-muted/50">
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Role</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Filename</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Size</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-muted-foreground">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.files as file}
|
||||||
|
{@const Icon = roleIcons[file.file_role] || FileText}
|
||||||
|
<tr class="border-b border-border last:border-0">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icon class="h-3.5 w-3.5" />
|
||||||
|
{roleLabels[file.file_role] || file.file_role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-xs">{file.filename}</td>
|
||||||
|
<td class="px-4 py-2 text-muted-foreground">{formatBytes(file.file_size)}</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<a
|
||||||
|
href="/api/reports/{data.report.id}/files/{file.id}"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-input px-2 py-1 text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Download class="h-3 w-3" />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No files attached.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation dialog -->
|
||||||
|
{#if showDeleteDialog}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (showDeleteDialog = false)}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="absolute inset-0" onclick={() => (showDeleteDialog = false)}></div>
|
||||||
|
<div class="relative rounded-lg border border-border bg-card p-6 shadow-xl max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-semibold">Delete Report</h3>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => (showDeleteDialog = false)}
|
||||||
|
class="rounded-md border border-input px-4 py-2 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={deleteReport}
|
||||||
|
disabled={deleting}
|
||||||
|
class="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/80 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
39
server/dashboard/src/routes/reports/[id]/+server.ts
Normal file
39
server/dashboard/src/routes/reports/[id]/+server.ts
Normal file
@@ -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 });
|
||||||
|
};
|
||||||
12
server/dashboard/svelte.config.js
Normal file
12
server/dashboard/svelte.config.js
Normal file
@@ -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;
|
||||||
14
server/dashboard/tsconfig.json
Normal file
14
server/dashboard/tsconfig.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
server/dashboard/vite.config.ts
Normal file
7
server/dashboard/vite.config.ts
Normal file
@@ -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()]
|
||||||
|
});
|
||||||
54
server/docker-compose.yml
Normal file
54
server/docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:lts
|
||||||
|
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
|
||||||
|
|
||||||
|
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:
|
||||||
16
server/emly-server.service
Normal file
16
server/emly-server.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=EMLy Bug Report Server (Docker Compose)
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory=/opt/emly-server
|
||||||
|
ExecStart=/usr/bin/docker compose up -d
|
||||||
|
ExecStop=/usr/bin/docker compose down
|
||||||
|
Restart=on-failure
|
||||||
|
TimeoutStartSec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
17
server/package.json
Normal file
17
server/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
server/src/config.ts
Normal file
24
server/src/config.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
28
server/src/db/connection.ts
Normal file
28
server/src/db/connection.ts
Normal file
@@ -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<void> {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/src/db/migrate.ts
Normal file
37
server/src/db/migrate.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { getPool } from "./connection";
|
||||||
|
|
||||||
|
export async function runMigrations(): Promise<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
38
server/src/db/schema.sql
Normal file
38
server/src/db/schema.sql
Normal file
@@ -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;
|
||||||
43
server/src/index.ts
Normal file
43
server/src/index.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
24
server/src/middleware/auth.ts
Normal file
24
server/src/middleware/auth.ts
Normal file
@@ -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 {};
|
||||||
|
}
|
||||||
|
);
|
||||||
70
server/src/middleware/rateLimit.ts
Normal file
70
server/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { getPool } from "../db/connection";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
const excludedHwids = new Set<string>([
|
||||||
|
// 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 {};
|
||||||
|
}
|
||||||
|
);
|
||||||
104
server/src/routes/admin.ts
Normal file
104
server/src/routes/admin.ts
Normal file
@@ -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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
101
server/src/routes/bugReports.ts
Normal file
101
server/src/routes/bugReports.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
163
server/src/services/bugReportService.ts
Normal file
163
server/src/services/bugReportService.ts
Normal file
@@ -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<string, unknown> | null;
|
||||||
|
}): Promise<number> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`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<number> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`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<PaginatedResponse<BugReportListItem>> {
|
||||||
|
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<RowDataPacket[]>(
|
||||||
|
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = (countRows[0] as { total: number }).total;
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
`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<BugReportFile, "data">[] } | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT * FROM bug_reports WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((reportRows as unknown[]).length === 0) return null;
|
||||||
|
|
||||||
|
const [fileRows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<BugReportFile, "data">[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFile(
|
||||||
|
reportId: number,
|
||||||
|
fileId: number
|
||||||
|
): Promise<BugReportFile | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"DELETE FROM bug_reports WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBugReportStatus(
|
||||||
|
id: number,
|
||||||
|
status: BugReportStatus
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"UPDATE bug_reports SET status = ? WHERE id = ?",
|
||||||
|
[status, id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
57
server/src/types/index.ts
Normal file
57
server/src/types/index.ts
Normal file
@@ -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<string, unknown> | 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<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user