diff --git a/engine/db/injects.go b/engine/db/injects.go index 205e434..5749a36 100644 --- a/engine/db/injects.go +++ b/engine/db/injects.go @@ -41,6 +41,16 @@ func GetInjects() ([]InjectSchema, error) { return injects, nil } +// GetInjectByID retrieves a single inject by ID +func GetInjectByID(id uint) (InjectSchema, error) { + var inject InjectSchema + result := db.Table("inject_schemas").First(&inject, id) + if result.Error != nil { + return InjectSchema{}, result.Error + } + return inject, nil +} + // UpdateInject func UpdateInject(inject InjectSchema) (InjectSchema, error) { result := db.Table("inject_schemas").Save(&inject) diff --git a/static/templates/pages/injects.html b/static/templates/pages/injects.html index d949ecb..a63257d 100644 --- a/static/templates/pages/injects.html +++ b/static/templates/pages/injects.html @@ -350,6 +350,9 @@ data-bs-target="#delete__form"> Delete + + Download All Submissions + {{ end }}
diff --git a/tests/integration/submissions_test.go b/tests/integration/submissions_test.go new file mode 100644 index 0000000..7ad1336 --- /dev/null +++ b/tests/integration/submissions_test.go @@ -0,0 +1,194 @@ +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "quotient/engine/db" + "quotient/tests/testutil" + "quotient/www/api" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDownloadAllSubmissions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + pgContainer := testutil.StartPostgres(t) + defer pgContainer.Close() + db.Connect(pgContainer.ConnectionString()) + + // Use temp dir for submission files + submissionsDir := t.TempDir() + originalWd, _ := os.Getwd() + os.Chdir(submissionsDir) + defer os.Chdir(originalWd) + + // Setup: team, inject, submissions + team, err := db.CreateTeam(db.TeamSchema{ + Name: fmt.Sprintf("Team-%d", time.Now().UnixNano()), + Identifier: "01", + Active: true, + }) + require.NoError(t, err, "failed to create team") + t.Logf("Created team ID: %d", team.ID) + + inject, err := db.CreateInject(db.InjectSchema{ + Title: fmt.Sprintf("Test Inject-%d", time.Now().UnixNano()), + OpenTime: time.Now().Add(-1 * time.Hour), + DueTime: time.Now().Add(1 * time.Hour), + CloseTime: time.Now().Add(2 * time.Hour), + }) + require.NoError(t, err, "failed to create inject") + t.Logf("Created inject ID: %d", inject.ID) + + // Create 2 submission versions + for v := 1; v <= 2; v++ { + dir := filepath.Join("submissions", fmt.Sprintf("%d/%d/%d", inject.ID, team.ID, v)) + err := os.MkdirAll(dir, 0750) + require.NoError(t, err, "failed to create dir") + + filename := fmt.Sprintf("report_v%d.pdf", v) + err = os.WriteFile(filepath.Join(dir, filename), []byte(fmt.Sprintf("content %d", v)), 0644) + require.NoError(t, err, "failed to write file") + + sub, err := db.CreateSubmission(db.SubmissionSchema{ + TeamID: team.ID, + InjectID: inject.ID, + SubmissionTime: time.Now(), + SubmissionFileName: filename, + }) + require.NoError(t, err, "failed to create submission") + t.Logf("Created submission version %d for team %d", sub.Version, team.ID) + } + + // Verify submissions exist + subs, err := db.GetSubmissionsForInject(inject.ID) + require.NoError(t, err) + t.Logf("Found %d submissions for inject %d", len(subs), inject.ID) + + // Make request + req := httptest.NewRequest("GET", fmt.Sprintf("/api/injects/%d/submissions/download", inject.ID), nil) + req.SetPathValue("id", fmt.Sprintf("%d", inject.ID)) + rr := httptest.NewRecorder() + + api.DownloadAllSubmissions(rr, req) + + // Verify ZIP response + if rr.Code != http.StatusOK { + t.Logf("Response body: %s", rr.Body.String()) + } + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/zip", rr.Header().Get("Content-Type")) + + zipReader, err := zip.NewReader(bytes.NewReader(rr.Body.Bytes()), int64(rr.Body.Len())) + require.NoError(t, err) + require.Len(t, zipReader.File, 2) + + // Verify files are in ZIP with correct content + for _, f := range zipReader.File { + rc, _ := f.Open() + content, _ := io.ReadAll(rc) + rc.Close() + assert.Contains(t, string(content), "content") + } +} + +func TestDownloadAllSubmissions_MultipleTeamsSameFilename(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + pgContainer := testutil.StartPostgres(t) + defer pgContainer.Close() + db.Connect(pgContainer.ConnectionString()) + + // Use temp dir for submission files + submissionsDir := t.TempDir() + originalWd, _ := os.Getwd() + os.Chdir(submissionsDir) + defer os.Chdir(originalWd) + + // Create inject + inject, err := db.CreateInject(db.InjectSchema{ + Title: fmt.Sprintf("Multi-Team Inject-%d", time.Now().UnixNano()), + OpenTime: time.Now().Add(-1 * time.Hour), + DueTime: time.Now().Add(1 * time.Hour), + CloseTime: time.Now().Add(2 * time.Hour), + }) + require.NoError(t, err) + t.Logf("Created inject ID: %d", inject.ID) + + // Create 3 teams, each submitting a file with the same name + numTeams := 3 + teams := make([]db.TeamSchema, numTeams) + for i := 0; i < numTeams; i++ { + team, err := db.CreateTeam(db.TeamSchema{ + Name: fmt.Sprintf("Team%d-%d", i+1, time.Now().UnixNano()), + Identifier: fmt.Sprintf("%02d", i+1), + Active: true, + }) + require.NoError(t, err) + teams[i] = team + t.Logf("Created team: %s (ID: %d)", team.Name, team.ID) + + // Each team submits "report.pdf" with unique content + dir := filepath.Join("submissions", fmt.Sprintf("%d/%d/1", inject.ID, team.ID)) + err = os.MkdirAll(dir, 0750) + require.NoError(t, err) + + content := fmt.Sprintf("Report from team %d", team.ID) + err = os.WriteFile(filepath.Join(dir, "report.pdf"), []byte(content), 0644) + require.NoError(t, err) + + _, err = db.CreateSubmission(db.SubmissionSchema{ + TeamID: team.ID, + InjectID: inject.ID, + SubmissionTime: time.Now(), + SubmissionFileName: "report.pdf", + }) + require.NoError(t, err) + } + + // Make request + req := httptest.NewRequest("GET", fmt.Sprintf("/api/injects/%d/submissions/download", inject.ID), nil) + req.SetPathValue("id", fmt.Sprintf("%d", inject.ID)) + rr := httptest.NewRecorder() + + api.DownloadAllSubmissions(rr, req) + + // Verify response + if rr.Code != http.StatusOK { + t.Logf("Response body: %s", rr.Body.String()) + } + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/zip", rr.Header().Get("Content-Type")) + + // Verify ZIP contains all 3 files (not overwritten) + zipReader, err := zip.NewReader(bytes.NewReader(rr.Body.Bytes()), int64(rr.Body.Len())) + require.NoError(t, err) + require.Len(t, zipReader.File, numTeams, "ZIP should contain one file per team") + + // Verify each file has unique content from different teams + contents := make(map[string]bool) + for _, f := range zipReader.File { + t.Logf("ZIP entry: %s", f.Name) + rc, _ := f.Open() + content, _ := io.ReadAll(rc) + rc.Close() + contents[string(content)] = true + } + + // All 3 team contents should be unique + assert.Len(t, contents, numTeams, "All team submissions should have unique content in ZIP") +} diff --git a/www/api/submissions.go b/www/api/submissions.go index 9641f93..9f66356 100644 --- a/www/api/submissions.go +++ b/www/api/submissions.go @@ -1,11 +1,14 @@ package api import ( + "archive/zip" "fmt" "io" + "log/slog" "math" "net/http" "os" + "path/filepath" "quotient/engine/db" "slices" "strconv" @@ -173,3 +176,65 @@ func DownloadSubmissionFile(w http.ResponseWriter, r *http.Request) { return } } + +func DownloadAllSubmissions(w http.ResponseWriter, r *http.Request) { + temp, err := strconv.ParseUint(r.PathValue("id"), 10, 32) + if err != nil { + WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid inject id"}) + return + } + injectID := uint(temp) + + if _, err := db.GetInjectByID(injectID); err != nil { + WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Inject not found"}) + return + } + + submissions, err := db.GetSubmissionsForInject(injectID) + if err != nil { + WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Error retrieving submissions"}) + return + } + + if len(submissions) == 0 { + WriteJSON(w, http.StatusNotFound, map[string]any{"error": "No submissions found"}) + return + } + + filename := fmt.Sprintf("inject%d_submissions.zip", injectID) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.Header().Set("Content-Type", "application/zip") + + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + + // SubmissionFileName is validated by os.Root at upload time; Team.Name is admin-controlled. + for _, submission := range submissions { + baseDir := fmt.Sprintf("submissions/%d/%d/%d", injectID, submission.TeamID, submission.Version) + file, err := SafeOpen(baseDir, submission.SubmissionFileName) + if err != nil { + continue + } + + zipPath := filepath.Join( + fmt.Sprintf("%s_v%d", submission.Team.Name, submission.Version), + submission.SubmissionFileName, + ) + + zipEntry, err := zipWriter.Create(zipPath) + if err != nil { + slog.Error("failed to create zip entry", "path", zipPath, "error", err) + if err := file.Close(); err != nil { + slog.Error("failed to close file", "path", zipPath, "error", err) + } + continue + } + + if _, err := io.Copy(zipEntry, file); err != nil { + slog.Error("failed to copy file to zip", "path", zipPath, "error", err) + } + if err := file.Close(); err != nil { + slog.Error("failed to close file", "path", zipPath, "error", err) + } + } +} diff --git a/www/router.go b/www/router.go index 8451208..595420c 100644 --- a/www/router.go +++ b/www/router.go @@ -149,6 +149,7 @@ func (router *Router) Start() { mux.HandleFunc("POST /api/injects/create", INJECTAUTH(api.CreateInject)) mux.HandleFunc("POST /api/injects/{id}", INJECTAUTH(api.UpdateInject)) mux.HandleFunc("DELETE /api/injects/{id}", INJECTAUTH(api.DeleteInject)) + mux.HandleFunc("GET /api/injects/{id}/submissions/download", INJECTAUTH(api.DownloadAllSubmissions)) // router.HandleFunc("POST /api/engine/service/create", ADMINAUTH(api.CreateService)) // router.HandleFunc("POST /api/engine/service/update", ADMINAUTH(api.UpdateService))