diff --git a/backend/.gitignore b/backend/.gitignore index 73b7cff..21fbe8d 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -42,4 +42,5 @@ bamort maintenance/testdata/* testdata/*_data.db tmp/main -uploads/* \ No newline at end of file +uploads/* +xporttemp/* \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 152e73c..c9760e2 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -68,6 +68,10 @@ func main() { maintenance.RegisterRoutes(protected) importer.RegisterRoutes(protected) pdfrender.RegisterRoutes(protected) + + // Register public routes (no authentication) + pdfrender.RegisterPublicRoutes(r) + logger.Info("API-Routen erfolgreich registriert") // Server starten diff --git a/backend/config/config.go b/backend/config/config.go index 37c6a7a..f593f7f 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -27,7 +27,8 @@ type Config struct { DevTesting string // "yes" or "no", used to determine if we are in a test environment // PDF Templates - TemplatesDir string // Directory where PDF templates are stored + TemplatesDir string // Directory where PDF templates are stored + ExportTempDir string // Directory for temporary PDF exports } // Cfg ist die globale Konfigurationsvariable @@ -42,14 +43,15 @@ func init() { // defaultConfig gibt die Standard-Konfiguration zurück func defaultConfig() *Config { return &Config{ - ServerPort: "8180", - DatabaseURL: "", - DatabaseType: "mysql", - DebugMode: false, - LogLevel: "INFO", - Environment: "production", - DevTesting: "no", // Default to "no", can be overridden in tests - TemplatesDir: "./templates", // Default templates directory + ServerPort: "8180", + DatabaseURL: "", + DatabaseType: "mysql", + DebugMode: false, + LogLevel: "INFO", + Environment: "production", + DevTesting: "no", // Default to "no", can be overridden in tests + TemplatesDir: "./templates", // Default templates directory + ExportTempDir: "./xporttemp", // Default export temp directory } } @@ -120,6 +122,11 @@ func LoadConfig() *Config { config.TemplatesDir = templatesDir } + // Export Temp Directory + if exportTempDir := os.Getenv("EXPORT_TEMP_DIR"); exportTempDir != "" { + config.ExportTempDir = exportTempDir + } + fmt.Printf("DEBUG LoadConfig - Finale Config: Environment='%s', DevTesting='%s', DatabaseType='%s'\n", config.Environment, config.DevTesting, config.DatabaseType) diff --git a/backend/pdfrender/file_management.go b/backend/pdfrender/file_management.go new file mode 100644 index 0000000..a3ad397 --- /dev/null +++ b/backend/pdfrender/file_management.go @@ -0,0 +1,82 @@ +package pdfrender + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// SanitizeFilename removes or replaces characters that are problematic in filenames +func SanitizeFilename(name string) string { + // Replace umlauts + replacements := map[string]string{ + "ä": "ae", "Ä": "Ae", + "ö": "oe", "Ö": "Oe", + "ü": "ue", "Ü": "Ue", + "ß": "ss", + } + + result := name + for old, new := range replacements { + result = strings.ReplaceAll(result, old, new) + } + + // Replace problematic characters with underscore + reg := regexp.MustCompile(`[^a-zA-Z0-9_.-]+`) + result = reg.ReplaceAllString(result, "_") + + // Remove consecutive underscores + reg = regexp.MustCompile(`_+`) + result = reg.ReplaceAllString(result, "_") + + // Trim underscores from start and end + result = strings.Trim(result, "_") + + return result +} + +// GenerateExportFilename creates a filename from character name and timestamp +func GenerateExportFilename(charName string, timestamp time.Time) string { + sanitizedName := SanitizeFilename(charName) + timeStr := timestamp.Format("20060102_150405") + return sanitizedName + "_" + timeStr + ".pdf" +} + +// EnsureExportTempDir creates the export temp directory if it doesn't exist +func EnsureExportTempDir(dir string) error { + return os.MkdirAll(dir, 0755) +} + +// CleanupOldFiles removes files older than maxAge from the directory +// Returns the number of files deleted +func CleanupOldFiles(dir string, maxAge time.Duration) (int, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + + count := 0 + cutoff := time.Now().Add(-maxAge) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filePath := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + + if info.ModTime().Before(cutoff) { + if err := os.Remove(filePath); err == nil { + count++ + } + } + } + + return count, nil +} diff --git a/backend/pdfrender/file_management_test.go b/backend/pdfrender/file_management_test.go new file mode 100644 index 0000000..4668b89 --- /dev/null +++ b/backend/pdfrender/file_management_test.go @@ -0,0 +1,182 @@ +package pdfrender + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func setupTestEnvironment(t *testing.T) { + original := os.Getenv("ENVIRONMENT") + os.Setenv("ENVIRONMENT", "test") + t.Cleanup(func() { + if original != "" { + os.Setenv("ENVIRONMENT", original) + } else { + os.Unsetenv("ENVIRONMENT") + } + }) +} + +func TestSanitizeFilename(t *testing.T) { + setupTestEnvironment(t) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "Simple name", + input: "Character", + expected: "Character", + }, + { + name: "Name with spaces", + input: "Fanjo Vetrani", + expected: "Fanjo_Vetrani", + }, + { + name: "Name with umlauts", + input: "Müller Ökonom", + expected: "Mueller_Oekonom", + }, + { + name: "Name with special chars", + input: "Test/Character\\Name:With*Special?Chars", + expected: "Test_Character_Name_With_Special_Chars", + }, + { + name: "Multiple consecutive spaces", + input: "Test Name", + expected: "Test_Name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeFilename(tt.input) + if result != tt.expected { + t.Errorf("SanitizeFilename(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGenerateExportFilename(t *testing.T) { + setupTestEnvironment(t) + + // Test with a fixed timestamp + timestamp := time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC) + filename := GenerateExportFilename("Fanjo Vetrani", timestamp) + + expectedPrefix := "Fanjo_Vetrani_20231225_143045" + if filename != expectedPrefix+".pdf" { + t.Errorf("GenerateExportFilename() = %q, want prefix %q", filename, expectedPrefix) + } + + // Test with special characters + filename2 := GenerateExportFilename("Test/Char*Name", timestamp) + expectedPrefix2 := "Test_Char_Name_20231225_143045" + if filename2 != expectedPrefix2+".pdf" { + t.Errorf("GenerateExportFilename() = %q, want prefix %q", filename2, expectedPrefix2) + } +} + +func TestEnsureExportTempDir(t *testing.T) { + setupTestEnvironment(t) + + // Use a test-specific temp directory + testDir := filepath.Join(os.TempDir(), "bamort_test_xporttemp") + defer os.RemoveAll(testDir) + + // First call should create the directory + err := EnsureExportTempDir(testDir) + if err != nil { + t.Fatalf("EnsureExportTempDir() error = %v", err) + } + + // Verify directory exists + info, err := os.Stat(testDir) + if err != nil { + t.Fatalf("Directory not created: %v", err) + } + + if !info.IsDir() { + t.Error("Path exists but is not a directory") + } + + // Second call should succeed (directory already exists) + err = EnsureExportTempDir(testDir) + if err != nil { + t.Errorf("EnsureExportTempDir() on existing directory error = %v", err) + } +} + +func TestCleanupOldFiles(t *testing.T) { + setupTestEnvironment(t) + + // Create test directory + testDir := filepath.Join(os.TempDir(), "bamort_test_cleanup") + os.RemoveAll(testDir) + defer os.RemoveAll(testDir) + + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Create test files with different ages + now := time.Now() + + // Old file (8 days old) + oldFile := filepath.Join(testDir, "old_file.pdf") + if err := os.WriteFile(oldFile, []byte("old"), 0644); err != nil { + t.Fatalf("Failed to create old file: %v", err) + } + oldTime := now.Add(-8 * 24 * time.Hour) + if err := os.Chtimes(oldFile, oldTime, oldTime); err != nil { + t.Fatalf("Failed to set old file time: %v", err) + } + + // Recent file (3 days old) + recentFile := filepath.Join(testDir, "recent_file.pdf") + if err := os.WriteFile(recentFile, []byte("recent"), 0644); err != nil { + t.Fatalf("Failed to create recent file: %v", err) + } + recentTime := now.Add(-3 * 24 * time.Hour) + if err := os.Chtimes(recentFile, recentTime, recentTime); err != nil { + t.Fatalf("Failed to set recent file time: %v", err) + } + + // New file (1 hour old) + newFile := filepath.Join(testDir, "new_file.pdf") + if err := os.WriteFile(newFile, []byte("new"), 0644); err != nil { + t.Fatalf("Failed to create new file: %v", err) + } + + // Cleanup files older than 7 days + count, err := CleanupOldFiles(testDir, 7*24*time.Hour) + if err != nil { + t.Fatalf("CleanupOldFiles() error = %v", err) + } + + if count != 1 { + t.Errorf("CleanupOldFiles() deleted %d files, want 1", count) + } + + // Verify old file is deleted + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Error("Old file should be deleted") + } + + // Verify recent file still exists + if _, err := os.Stat(recentFile); err != nil { + t.Error("Recent file should still exist") + } + + // Verify new file still exists + if _, err := os.Stat(newFile); err != nil { + t.Error("New file should still exist") + } +} diff --git a/backend/pdfrender/handlers.go b/backend/pdfrender/handlers.go index 5988c21..ae976dd 100644 --- a/backend/pdfrender/handlers.go +++ b/backend/pdfrender/handlers.go @@ -45,10 +45,12 @@ func ListTemplates(c *gin.Context) { c.JSON(http.StatusOK, templates) } -// ExportCharacterToPDF exports a character to PDF +// ExportCharacterToPDF exports a character to PDF and saves it to xporttemp directory // Query params: // - template: template ID to use (default: "Default_A4_Quer") // - showUserName: whether to show user name (default: false) +// +// Returns JSON with filename: {"filename": "CharacterName_20231225_143045.pdf"} func ExportCharacterToPDF(c *gin.Context) { // Get character ID charID := c.Param("id") @@ -120,47 +122,110 @@ func ExportCharacterToPDF(c *gin.Context) { } allPDFs = append(allPDFs, page4PDFs...) - // If only one PDF, return it directly + // Merge PDFs if needed + var finalPDF []byte if len(allPDFs) == 1 { - c.Data(http.StatusOK, "application/pdf", allPDFs[0]) - return - } - - // Merge multiple PDFs - tmpDir := "/tmp/bamort_pdf_export" - if err := os.MkdirAll(tmpDir, 0755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp directory"}) - return - } - defer os.RemoveAll(tmpDir) - - // Save individual PDFs - var filePaths []string - for i, pdf := range allPDFs { - filename := fmt.Sprintf("%s/page_%d.pdf", tmpDir, i) - if err := os.WriteFile(filename, pdf, 0644); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write temporary PDF"}) + finalPDF = allPDFs[0] + } else { + // Merge multiple PDFs + tmpDir := "/tmp/bamort_pdf_export" + if err := os.MkdirAll(tmpDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp directory"}) + return + } + defer os.RemoveAll(tmpDir) + + // Save individual PDFs + var filePaths []string + for i, pdf := range allPDFs { + filename := fmt.Sprintf("%s/page_%d.pdf", tmpDir, i) + if err := os.WriteFile(filename, pdf, 0644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write temporary PDF"}) + return + } + filePaths = append(filePaths, filename) + } + + // Merge PDFs + combinedPath := fmt.Sprintf("%s/combined.pdf", tmpDir) + if err := api.MergeCreateFile(filePaths, combinedPath, false, nil); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to merge PDFs: " + err.Error()}) + return + } + + // Read combined PDF + finalPDF, err = os.ReadFile(combinedPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read combined PDF"}) return } - filePaths = append(filePaths, filename) } - // Merge PDFs - combinedPath := fmt.Sprintf("%s/combined.pdf", tmpDir) - if err := api.MergeCreateFile(filePaths, combinedPath, false, nil); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to merge PDFs: " + err.Error()}) + // Ensure export temp directory exists + if err := EnsureExportTempDir(config.Cfg.ExportTempDir); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export directory"}) return } - // Read combined PDF - combinedPDF, err := os.ReadFile(combinedPath) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read combined PDF"}) + // Generate filename + filename := GenerateExportFilename(char.Name, time.Now()) + filePath := filepath.Join(config.Cfg.ExportTempDir, filename) + + // Save PDF to file + if err := os.WriteFile(filePath, finalPDF, 0644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save PDF file: " + err.Error()}) return } - // Set response headers - filename := fmt.Sprintf("%s_character_sheet.pdf", char.Name) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - c.Data(http.StatusOK, "application/pdf", combinedPDF) + // Return filename + c.JSON(http.StatusOK, gin.H{"filename": filename}) +} + +// GetPDFFile serves a PDF file from the xporttemp directory +func GetPDFFile(c *gin.Context) { + filename := c.Param("filename") + if filename == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Filename is required"}) + return + } + + // Prevent path traversal attacks - only allow base filename + if filepath.Base(filename) != filename { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filename"}) + return + } + + // Only allow .pdf files + if filepath.Ext(filename) != ".pdf" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only PDF files are allowed"}) + return + } + + // Construct full path + filePath := filepath.Join(config.Cfg.ExportTempDir, filename) + + // Check if file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + // Serve the file + c.File(filePath) +} + +// CleanupExportTemp removes PDF files older than 7 days from xporttemp directory +func CleanupExportTemp(c *gin.Context) { + // Clean up files older than 7 days + maxAge := 7 * 24 * time.Hour + count, err := CleanupOldFiles(config.Cfg.ExportTempDir, maxAge) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to cleanup files: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "deleted": count, + "message": fmt.Sprintf("Deleted %d files older than 7 days", count), + }) } diff --git a/backend/pdfrender/handlers_test.go b/backend/pdfrender/handlers_test.go index 0a97cf2..31f238f 100644 --- a/backend/pdfrender/handlers_test.go +++ b/backend/pdfrender/handlers_test.go @@ -7,7 +7,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/gin-gonic/gin" ) @@ -15,6 +19,7 @@ import ( func init() { // Set templates directory for tests (tests run from pdfrender/ directory) config.Cfg.TemplatesDir = "../templates" + config.Cfg.ExportTempDir = "../xporttemp" } func TestListTemplates(t *testing.T) { @@ -59,6 +64,14 @@ func TestExportCharacterToPDF(t *testing.T) { // Arrange database.SetupTestDB() + // Use test-specific temp directory + testDir := "../xporttemp_test_basic" + config.Cfg.ExportTempDir = testDir + defer func() { + os.RemoveAll(testDir) + config.Cfg.ExportTempDir = "../xporttemp" + }() + // Load test character char := &models.Char{} err := char.FirstID("18") @@ -77,24 +90,41 @@ func TestExportCharacterToPDF(t *testing.T) { // Assert if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) } - // Check content type + // Check content type - now returns JSON contentType := w.Header().Get("Content-Type") - if contentType != "application/pdf" { - t.Errorf("Expected Content-Type 'application/pdf', got '%s'", contentType) + if !strings.Contains(contentType, "application/json") { + t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType) } - // Check PDF content - body := w.Body.Bytes() - if len(body) == 0 { - t.Error("Expected non-empty PDF content") + // Parse JSON response + var response map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + // Verify filename is returned + filename, ok := response["filename"] + if !ok { + t.Fatal("Response should contain 'filename' field") + } + + if filename == "" { + t.Error("Filename should not be empty") + } + + // Verify PDF file exists + filePath := filepath.Join(testDir, filename) + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read PDF file: %v", err) } // Verify PDF marker - if string(body[0:4]) != "%PDF" { - t.Error("Response does not start with PDF marker") + if string(data[0:4]) != "%PDF" { + t.Error("File does not start with PDF marker") } } @@ -102,6 +132,14 @@ func TestExportCharacterToPDF_WithTemplate(t *testing.T) { // Arrange database.SetupTestDB() + // Use test-specific temp directory + testDir := "../xporttemp_test_template" + config.Cfg.ExportTempDir = testDir + defer func() { + os.RemoveAll(testDir) + config.Cfg.ExportTempDir = "../xporttemp" + }() + gin.SetMode(gin.TestMode) router := gin.New() router.GET("/export/:id", ExportCharacterToPDF) @@ -113,13 +151,26 @@ func TestExportCharacterToPDF_WithTemplate(t *testing.T) { // Assert if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) } + // Parse JSON response + var response map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + filename := response["filename"] + filePath := filepath.Join(testDir, filename) + // Verify it's a PDF - body := w.Body.Bytes() - if string(body[0:4]) != "%PDF" { - t.Error("Response does not start with PDF marker") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read PDF: %v", err) + } + + if string(data[0:4]) != "%PDF" { + t.Error("File does not start with PDF marker") } } @@ -141,3 +192,270 @@ func TestExportCharacterToPDF_CharacterNotFound(t *testing.T) { t.Errorf("Expected status 404, got %d", w.Code) } } + +func TestExportCharacterToPDF_SavesFileAndReturnsFilename(t *testing.T) { + // Arrange + database.SetupTestDB() + + // Use test-specific temp directory + testDir := "../xporttemp_test" + config.Cfg.ExportTempDir = testDir + defer func() { + // Cleanup + os.RemoveAll(testDir) + config.Cfg.ExportTempDir = "../xporttemp" + }() + + // Load test character + char := &models.Char{} + err := char.FirstID("18") + if err != nil { + t.Fatalf("Failed to load test character: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/export/:id", ExportCharacterToPDF) + + // Act + req, _ := http.NewRequest("GET", "/export/18", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Parse response + var response map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse JSON response: %v", err) + } + + // Check that filename is returned + filename, ok := response["filename"] + if !ok { + t.Fatal("Response should contain 'filename' field") + } + + if filename == "" { + t.Error("Filename should not be empty") + } + + // Verify filename format (should contain character name and timestamp) + if !strings.Contains(filename, "Fanjo_Vetrani") { + t.Errorf("Filename should contain sanitized character name, got: %s", filename) + } + + if !strings.HasSuffix(filename, ".pdf") { + t.Errorf("Filename should end with .pdf, got: %s", filename) + } + + // Verify file exists in xporttemp directory + filePath := filepath.Join(testDir, filename) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("PDF file should exist at %s", filePath) + } + + // Verify it's a valid PDF + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read PDF file: %v", err) + } + + if len(data) == 0 { + t.Error("PDF file should not be empty") + } + + if string(data[0:4]) != "%PDF" { + t.Error("File should be a valid PDF") + } +} + +func TestGetPDFFile(t *testing.T) { + setupTestEnvironment(t) + + // Create test directory and file + testDir := "../xporttemp_test_get" + config.Cfg.ExportTempDir = testDir + defer func() { + os.RemoveAll(testDir) + config.Cfg.ExportTempDir = "../xporttemp" + }() + + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Create a test PDF file + testFilename := "Test_Character_20231225_120000.pdf" + testPDFContent := []byte("%PDF-1.4\nTest PDF Content") + filePath := filepath.Join(testDir, testFilename) + if err := os.WriteFile(filePath, testPDFContent, 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/file/:filename", GetPDFFile) + + // Act - Get the PDF file + req, _ := http.NewRequest("GET", "/file/"+testFilename, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Check content type + contentType := w.Header().Get("Content-Type") + if contentType != "application/pdf" { + t.Errorf("Expected Content-Type 'application/pdf', got '%s'", contentType) + } + + // Check content + body := w.Body.Bytes() + if string(body) != string(testPDFContent) { + t.Errorf("Expected PDF content, got different content") + } +} + +func TestGetPDFFile_NotFound(t *testing.T) { + setupTestEnvironment(t) + + testDir := "../xporttemp_test_notfound" + config.Cfg.ExportTempDir = testDir + defer func() { + os.RemoveAll(testDir) + config.Cfg.ExportTempDir = "../xporttemp" + }() + + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/file/:filename", GetPDFFile) + + // Act - Try to get non-existent file + req, _ := http.NewRequest("GET", "/file/nonexistent.pdf", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestGetPDFFile_PathTraversal(t *testing.T) { + setupTestEnvironment(t) + + testDir := "../xporttemp_test_security" + config.Cfg.ExportTempDir = testDir + defer func() { + os.RemoveAll(testDir) + config.Cfg.ExportTempDir = "../xporttemp" + }() + + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/file/:filename", GetPDFFile) + + // Act - Try path traversal attack + req, _ := http.NewRequest("GET", "/file/../../../etc/passwd", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert - Should return error (either 404 or 400) + if w.Code == http.StatusOK { + t.Error("Should not allow path traversal attacks") + } +} + +func TestCleanupEndpoint(t *testing.T) { + setupTestEnvironment(t) + + // Create test directory with old and new files + testDir := "../xporttemp_test_cleanup_endpoint" + config.Cfg.ExportTempDir = testDir + defer func() { + os.RemoveAll(testDir) + config.Cfg.ExportTempDir = "../xporttemp" + }() + + if err := os.MkdirAll(testDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + now := time.Now() + + // Create old file (8 days old) + oldFile := filepath.Join(testDir, "old_file.pdf") + if err := os.WriteFile(oldFile, []byte("%PDF-old"), 0644); err != nil { + t.Fatalf("Failed to create old file: %v", err) + } + oldTime := now.Add(-8 * 24 * time.Hour) + if err := os.Chtimes(oldFile, oldTime, oldTime); err != nil { + t.Fatalf("Failed to set old file time: %v", err) + } + + // Create recent file (3 days old) + recentFile := filepath.Join(testDir, "recent_file.pdf") + if err := os.WriteFile(recentFile, []byte("%PDF-recent"), 0644); err != nil { + t.Fatalf("Failed to create recent file: %v", err) + } + recentTime := now.Add(-3 * 24 * time.Hour) + if err := os.Chtimes(recentFile, recentTime, recentTime); err != nil { + t.Fatalf("Failed to set recent file time: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/cleanup", CleanupExportTemp) + + // Act + req, _ := http.NewRequest("POST", "/cleanup", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Assert + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Parse response + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + // Check deleted count + deletedCount, ok := response["deleted"] + if !ok { + t.Fatal("Response should contain 'deleted' field") + } + + // Should have deleted 1 file (the old one) + if int(deletedCount.(float64)) != 1 { + t.Errorf("Expected 1 file deleted, got %v", deletedCount) + } + + // Verify old file is deleted + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Error("Old file should be deleted") + } + + // Verify recent file still exists + if _, err := os.Stat(recentFile); err != nil { + t.Error("Recent file should still exist") + } +} diff --git a/backend/pdfrender/routes.go b/backend/pdfrender/routes.go index 93af172..b08aa78 100644 --- a/backend/pdfrender/routes.go +++ b/backend/pdfrender/routes.go @@ -4,12 +4,22 @@ import ( "github.com/gin-gonic/gin" ) +// RegisterRoutes registers protected PDF routes func RegisterRoutes(r *gin.RouterGroup) { pdfGrp := r.Group("/pdf") - // List available templates + // List available templates (protected) pdfGrp.GET("/templates", ListTemplates) - // Export character to PDF + // Export character to PDF (protected) pdfGrp.GET("/export/:id", ExportCharacterToPDF) + + // Cleanup old PDF files (protected) + pdfGrp.POST("/cleanup", CleanupExportTemp) +} + +// RegisterPublicRoutes registers public PDF routes (no authentication required) +func RegisterPublicRoutes(r *gin.Engine) { + // Get PDF file from xporttemp (public - for direct browser access) + r.GET("/api/pdf/file/:filename", GetPDFFile) } diff --git a/backend/pdfrender/todo.md b/backend/pdfrender/todo.md index 3b108bc..d10a56e 100644 --- a/backend/pdfrender/todo.md +++ b/backend/pdfrender/todo.md @@ -104,4 +104,5 @@ * currently the template fetched for rendering is set to Default_A4_Quer * remove inline css as far as possible -* make pdf download popup an own view \ No newline at end of file +* make pdf download popup an own view +* func CleanupExportTemp move maxAge := 7 * 24 * time.Hour definition to Config struct n config.go \ No newline at end of file diff --git a/frontend/src/components/CharacterDetails.vue b/frontend/src/components/CharacterDetails.vue index 8b072c7..4502ef7 100644 --- a/frontend/src/components/CharacterDetails.vue +++ b/frontend/src/components/CharacterDetails.vue @@ -409,65 +409,6 @@ export default { return } - // Open window IMMEDIATELY (synchronously) to avoid popup blocker - const pdfWindow = window.open('', '_blank') - if (!pdfWindow) { - alert(this.$t('export.popupBlocked')) - return - } - - // Show loading page in the new window - pdfWindow.document.write(` - -
-${this.$t('export.pleaseWait')}
-