diff --git a/package-lock.json b/package-lock.json index 91c6bd2..c662cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-s3": "^3.1081.0", + "@aws-sdk/s3-request-presigner": "^3.1128.0", "@nestjs/common": "^11.1.24", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", @@ -208,17 +209,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.29.tgz", - "integrity": "sha512-yqKcltLbtRh1ubzhRSldIs8jFHNZlyMlgoIccCC0aDVbrB99nXaBdmfr89mK7obWX/NVg4rAMpCpZ6dCDiVBtA==", + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.15", - "@aws-sdk/xml-builder": "^3.972.33", + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.0", - "@smithy/signature-v4": "^5.6.1", - "@smithy/types": "^4.15.1", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -410,15 +411,32 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", - "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1128.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1128.0.tgz", + "integrity": "sha512-0wTgUw97lUKx94e4liC53V5z9giwqMaPsFjbXXy8qLC9Q/AaxUySmri+NFkHaOAd908sGsZ6ERMyFO4WMvYxLQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.15", - "@smithy/signature-v4": "^5.6.1", - "@smithy/types": "^4.15.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -443,12 +461,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", - "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.15.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -456,12 +474,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", - "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.15.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2915,12 +2933,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.29.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.1.tgz", - "integrity": "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==", + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.15.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2970,13 +2988,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", - "integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.1", - "@smithy/types": "^4.15.1", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2984,9 +3002,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.15.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", - "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" diff --git a/package.json b/package.json index e501270..ce80aba 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1081.0", + "@aws-sdk/s3-request-presigner": "^3.1128.0", "@nestjs/common": "^11.1.24", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", diff --git a/prisma/migrations/20260909060000_group_course_scope/migration.sql b/prisma/migrations/20260909060000_group_course_scope/migration.sql new file mode 100644 index 0000000..ebfeb9a --- /dev/null +++ b/prisma/migrations/20260909060000_group_course_scope/migration.sql @@ -0,0 +1,19 @@ +ALTER TABLE "student_groups" ADD COLUMN "course_id" UUID; +CREATE INDEX "student_groups_course_id_idx" ON "student_groups"("course_id"); +ALTER TABLE "student_groups" ADD CONSTRAINT "student_groups_course_id_fkey" + FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Preserve only unambiguous historical course associations. Empty groups without +-- curriculum history cannot be attributed to an instructor safely. +WITH candidates AS ( + SELECT "group_id", "course_id" FROM "instructor_curriculum_settings" WHERE "group_id" IS NOT NULL + UNION + SELECT m."group_id", e."course_id" + FROM "student_group_members" m + JOIN "student_enrollments" e ON e."user_id" = m."user_id" AND e."status" = 'ACTIVE' +), unique_courses AS ( + SELECT "group_id", MIN("course_id"::text)::uuid AS "course_id" + FROM candidates GROUP BY "group_id" HAVING COUNT(DISTINCT "course_id") = 1 +) +UPDATE "student_groups" g SET "course_id" = c."course_id" +FROM unique_courses c WHERE g."id" = c."group_id"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2345bc8..77026b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,8 @@ model StudentEnrollment { model StudentGroup { id String @id @default(uuid()) @db.Uuid + courseId String? @map("course_id") @db.Uuid + course Course? @relation(fields: [courseId], references: [id], onDelete: Restrict) organizationId String? @map("organization_id") @db.Uuid organizationName String @map("organization_name") name String @@ -84,6 +86,7 @@ model StudentGroup { teamCustomModules TeamCustomModule[] @@unique([organizationName, name]) + @@index([courseId]) @@index([organizationId]) @@index([organizationName]) @@index([instructorPlatformId]) @@ -168,6 +171,7 @@ model Course { customModules StudentCustomModule[] teamCustomModules TeamCustomModule[] submissionScores SubmissionScore[] + studentGroups StudentGroup[] submissionTotalScores SubmissionTotalScore[] @@index([isActive]) diff --git a/src/instructor/group-course-scope.spec.ts b/src/instructor/group-course-scope.spec.ts new file mode 100644 index 0000000..9072841 --- /dev/null +++ b/src/instructor/group-course-scope.spec.ts @@ -0,0 +1,71 @@ +import { InstructorService } from './instructor.service'; +import { groupCourseScope } from './group-course-scope'; + +describe('group course ownership', () => { + const setup = () => { + const tx = { + studentGroup: { create: jest.fn().mockResolvedValue({ id: 'group' }), update: jest.fn() }, + studentGroupMember: { deleteMany: jest.fn(), createMany: jest.fn() }, + }; + const service = Object.assign(Object.create(InstructorService.prototype), { + prisma: { $transaction: async (callback: (client: typeof tx) => Promise) => callback(tx) }, + getInstructor: jest.fn().mockResolvedValue({ + authUser: { primaryAffiliation: { organizationId: 'org', organizationName: 'Org' } }, + instructor: { id: 'teacher' }, + }), + findInstructorCourse: jest.fn().mockResolvedValue({ id: 'course' }), + validateActiveStudentIds: jest.fn().mockResolvedValue([]), + resolveInstructorPlatformAssignment: jest.fn().mockResolvedValue(null), + queuePlatformAccessPolicy: jest.fn(), + listGroups: jest.fn().mockResolvedValue({ groups: [{ id: 'group', members: [] }] }), + }) as InstructorService; + return { service, tx }; + }; + + it('creates a course-owned group even with no students', async () => { + const { service, tx } = setup(); + await expect(service.createGroup('token', { name: 'Empty', memberIds: [], courseId: 'course' })) + .resolves.toEqual({ groups: [{ id: 'group', members: [] }] }); + expect(tx.studentGroup.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ courseId: 'course', name: 'Empty' }), + })); + expect(tx.studentGroupMember.createMany).not.toHaveBeenCalled(); + }); + + it('rejects a nonexistent or unauthorized course before inserting', async () => { + const { service, tx } = setup(); + jest.spyOn(service as never, 'findInstructorCourse' as never).mockResolvedValue(null as never); + await expect(service.createGroup('token', { name: 'Empty', memberIds: [], courseId: 'other' })).rejects.toThrow('Course not found'); + expect(tx.studentGroup.create).not.toHaveBeenCalled(); + }); + + it('does not require members for the selected course', () => { + const scope = groupCourseScope('teacher', 'course'); + expect(scope.OR?.[0]).toEqual({ courseId: 'course' }); + expect(scope.OR?.[1]).toEqual({ + courseId: null, + members: { some: { user: { enrollments: { some: { status: 'ACTIVE', courseId: 'course' } } } } }, + }); + }); + + it('keeps the course association when the last member is removed', async () => { + const { service, tx } = setup(); + Object.assign(service, { + findInstructorGroup: jest.fn().mockResolvedValue({ id: 'group' }), + getGroupPlatformAssignment: jest.fn().mockResolvedValue({ memberIds: ['student'], instructorPlatformId: null }), + prepareGroupPlatformTermination: jest.fn().mockResolvedValue({ sessionIds: [], queueUserIds: [], notices: [] }), + notifyPlatformTerminatedSessions: jest.fn(), + }); + await service.updateGroup('token', 'group', { name: 'Empty now', memberIds: [], courseId: 'course' }); + expect(tx.studentGroup.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ courseId: 'course' }), + })); + expect(tx.studentGroupMember.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group' } }); + }); + + it('scopes unselected-course queries to the instructor instead of all empty groups', () => { + const scope = groupCourseScope('teacher'); + expect(scope.OR?.[0]).toEqual({ course: { teacherId: 'teacher' } }); + expect(scope.OR?.[1]).toHaveProperty('courseId', null); + }); +}); diff --git a/src/instructor/group-course-scope.ts b/src/instructor/group-course-scope.ts new file mode 100644 index 0000000..939e1cc --- /dev/null +++ b/src/instructor/group-course-scope.ts @@ -0,0 +1,14 @@ +import type { Prisma } from '@prisma/client'; + +export function groupCourseScope(instructorId: string, courseId?: string | null): Prisma.StudentGroupWhereInput { + const enrollmentScope = courseId ? { courseId } : { course: { teacherId: instructorId } }; + return { + OR: [ + courseId ? { courseId } : { course: { teacherId: instructorId } }, + { + courseId: null, + members: { some: { user: { enrollments: { some: { status: 'ACTIVE', ...enrollmentScope } } } } }, + }, + ], + }; +} diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index 1219412..6a78480 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -22,6 +22,7 @@ import { R2PublicFileService } from '../storage/r2-public-file.service'; import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto'; import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; +import { groupCourseScope } from './group-course-scope'; import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto'; import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto'; @@ -1126,6 +1127,8 @@ export class InstructorService { ? { courseId: course.id } : { course: { teacherId: instructor.id } }; + if (courseId?.trim() && !course) throw new NotFoundException('Course not found'); + const groups = await this.prisma.studentGroup.findMany({ where: { AND: [ @@ -1135,20 +1138,7 @@ export class InstructorService { organizationName ? { organizationName } : undefined, ].filter(Boolean) as Prisma.StudentGroupWhereInput[], }, - { - members: { - some: { - user: { - enrollments: { - some: { - status: 'ACTIVE', - ...enrollmentScope, - }, - }, - }, - }, - }, - }, + groupCourseScope(instructor.id, course?.id), ], }, orderBy: { name: 'asc' }, @@ -2568,6 +2558,9 @@ export class InstructorService { const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); if (!organizationName) throw new BadRequestException('Instructor organization is required'); + const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId); + if (!course) throw new NotFoundException('Course not found'); + const name = input.name.trim(); if (!name) throw new BadRequestException('Group name is required'); @@ -2584,6 +2577,7 @@ export class InstructorService { await this.prisma.$transaction(async (tx) => { const group = await tx.studentGroup.create({ data: { + courseId: course.id, organizationId, organizationName, name, @@ -2637,7 +2631,9 @@ export class InstructorService { const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); if (!organizationName) throw new BadRequestException('Instructor organization is required'); - const group = await this.findInstructorGroup(groupId, organizationId, organizationName); + const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId); + if (!course) throw new NotFoundException('Course not found'); + const group = await this.findInstructorGroup(groupId, organizationId, organizationName, instructor.id, course.id); const name = input.name.trim(); if (!name) throw new BadRequestException('Group name is required'); const memberIds = await this.validateActiveStudentIds( @@ -2665,6 +2661,7 @@ export class InstructorService { await tx.studentGroup.update({ where: { id: group.id }, data: { + courseId: course.id, name, instructorPlatformId: assignedPlatform?.id ?? null, platformName: assignedPlatform?.displayName ?? null, @@ -2734,12 +2731,14 @@ export class InstructorService { } async deleteGroup(accessToken: string | undefined, groupId: string, courseId?: string | null) { - const { authUser } = await this.getInstructor(accessToken); + const { authUser, instructor } = await this.getInstructor(accessToken); const organizationId = authUser.primaryAffiliation?.organizationId; const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); if (!organizationName) throw new BadRequestException('Instructor organization is required'); - const group = await this.findInstructorGroup(groupId, organizationId, organizationName); + const course = await this.findInstructorCourse(organizationName, instructor.id, courseId); + if (!course) throw new NotFoundException('Course not found'); + const group = await this.findInstructorGroup(groupId, organizationId, organizationName, instructor.id, course.id); const previousAssignment = await this.getGroupPlatformAssignment(group.id); const termination = await this.prepareGroupPlatformTermination({ groupId: group.id, @@ -3004,10 +3003,13 @@ export class InstructorService { groupId: string, organizationId: string | undefined, organizationName: string, + instructorId: string, + courseId: string, ) { const group = await this.prisma.studentGroup.findFirst({ where: { id: groupId, + AND: [groupCourseScope(instructorId, courseId)], OR: [ organizationId ? { organizationId } : undefined, { organizationName }, diff --git a/src/storage/r2-public-file.service.spec.ts b/src/storage/r2-public-file.service.spec.ts new file mode 100644 index 0000000..60360f6 --- /dev/null +++ b/src/storage/r2-public-file.service.spec.ts @@ -0,0 +1,18 @@ +import { ConfigService } from '@nestjs/config'; +import { R2PublicFileService } from './r2-public-file.service'; + +describe('R2 direct upload URL', () => { + it('signs a PUT without a checksum for an empty body', async () => { + const service = new R2PublicFileService(new ConfigService({ + R2_BUCKET: 'test', R2_ACCOUNT_ID: 'test', + R2_PUBLIC_BASE_URL: 'https://files.example.com', + R2_ACCESS_KEY_ID: 'test', R2_SECRET_ACCESS_KEY: 'test', + })); + const url = new URL((await service.createUploadUrl('studio-files/public/test.db'))!); + expect(url.protocol).toBe('https:'); + expect(url.searchParams.get('X-Amz-Expires')).toBe('3600'); + expect(url.searchParams.has('X-Amz-Signature')).toBe(true); + expect(url.searchParams.has('x-amz-checksum-crc32')).toBe(false); + expect(await service.createUploadUrl('private/test.db')).toBeNull(); + }); +}); diff --git a/src/storage/r2-public-file.service.ts b/src/storage/r2-public-file.service.ts index e4fa4b2..3dce5bd 100644 --- a/src/storage/r2-public-file.service.ts +++ b/src/storage/r2-public-file.service.ts @@ -1,4 +1,5 @@ -import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -86,6 +87,21 @@ export class R2PublicFileService { })); } + async createUploadUrl(key: string) { + if (!this.isConfigured() || !this.isPublicKey(key)) return null; + return getSignedUrl(this.getClient(), new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + ContentType: 'application/octet-stream', + }), { expiresIn: 3600 }); + } + + async verifyUploadedFile(key: string) { + if (!this.isConfigured() || !this.isPublicKey(key)) return null; + const result = await this.getClient().send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); + return (result.ContentLength ?? 0) > 0 ? this.publicUrl(key) : null; + } + publicUrl(key: string) { if (!this.publicBaseUrl || !this.isPublicKey(key)) return null; return `${this.publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`; @@ -109,6 +125,8 @@ export class R2PublicFileService { secretAccessKey: this.secretAccessKey, }, forcePathStyle: this.configService.get('R2_FORCE_PATH_STYLE', 'true') !== 'false', + // Presigned PUT bodies are supplied later by the platform, not this client. + requestChecksumCalculation: 'WHEN_REQUIRED', }); return this.client; diff --git a/src/studio/studio-map-upload.spec.ts b/src/studio/studio-map-upload.spec.ts new file mode 100644 index 0000000..1997961 --- /dev/null +++ b/src/studio/studio-map-upload.spec.ts @@ -0,0 +1,76 @@ +import { ConfigService } from '@nestjs/config'; +import { StudioService } from './studio.service'; + +describe('remote map database upload', () => { + const uploadId = 'de091bb8-9a0a-48f9-82ae-439fa55cc71a'; + const input = { + courseId: 'course', week: '1', yamlPath: '/workspace/maps/test.yaml', + pgmPath: '/workspace/maps/test.pgm', databasePath: '/workspace/maps/test.db', + sourceMapKey: 'platform-3d', viewerModel: 'mkmini', runtimeKind: 'PLATFORM_3D', + databaseUploadId: uploadId, + }; + const files = { + yaml: { originalname: 'test.yaml', buffer: Buffer.from('yaml') }, + pgm: { originalname: 'test.pgm', buffer: Buffer.from('pgm') }, + }; + const setup = () => { + const prisma = { + user: { findUnique: jest.fn().mockResolvedValue({ id: 'student' }) }, + studentEnrollment: { findFirst: jest.fn().mockResolvedValue({ id: 'enrollment' }) }, + studentSavedMap: { + findUnique: jest.fn().mockResolvedValue(null), + upsert: jest.fn().mockImplementation(({ create }) => create), + }, + }; + const storage = { + isConfigured: () => true, + createLmsSavedMapFileKey: jest.fn(({ userId, courseId, week, mapId, fileName }) => + `${userId}/${courseId}/${week}/${mapId}/${fileName}`), + createUploadUrl: jest.fn().mockResolvedValue('https://example.r2.cloudflarestorage.com/upload'), + verifyUploadedFile: jest.fn().mockResolvedValue('https://files.example/test.db'), + uploadPublicFile: jest.fn().mockResolvedValue('https://files.example/small-file'), + }; + const service = new StudioService( + { me: jest.fn().mockResolvedValue({ user: { email: 'student@example.com' } }) } as never, + new ConfigService(), prisma as never, storage as never, + ); + return { service, storage, prisma }; + }; + + it('issues an upload only for an enrolled student and a flat database filename', async () => { + const { service, storage, prisma } = setup(); + await expect(service.prepareDatabaseUpload('token', { courseId: 'course', week: 1, databasePath: input.databasePath })) + .resolves.toHaveProperty('uploadUrl'); + expect(storage.createLmsSavedMapFileKey).toHaveBeenCalledWith(expect.objectContaining({ userId: 'student', courseId: 'course', week: 1 })); + await expect(service.prepareDatabaseUpload('token', { courseId: 'course', week: 1, databasePath: '/workspace/maps/../test.db' })).rejects.toThrow(); + prisma.studentEnrollment.findFirst.mockResolvedValue(null); + await expect(service.prepareDatabaseUpload('token', { courseId: 'other', week: 1, databasePath: input.databasePath })).rejects.toThrow(); + expect(storage.createUploadUrl).toHaveBeenCalledTimes(1); + }); + + it('verifies an owner-scoped R2 object before recording and uploads only YAML/PGM', async () => { + const { service, storage } = setup(); + const result = await service.uploadSavedMap('token', input, files); + expect(storage.verifyUploadedFile).toHaveBeenCalledWith(`student/course/1/${uploadId}/test.db`); + expect(storage.uploadPublicFile).toHaveBeenCalledTimes(2); + expect(result.databaseUrl).toBe('https://files.example/test.db'); + }); + + it('does not register a missing or incomplete database', async () => { + const { service, storage, prisma } = setup(); + storage.verifyUploadedFile.mockResolvedValue(null); + await expect(service.uploadSavedMap('token', input, files)).rejects.toThrow('Database upload is not complete'); + expect(prisma.studentSavedMap.upsert).not.toHaveBeenCalled(); + expect(storage.uploadPublicFile).not.toHaveBeenCalled(); + }); + + it('retains the existing multipart 2D and 3D paths', async () => { + const { service, storage } = setup(); + await service.uploadSavedMap('token', { ...input, databaseUploadId: undefined, databasePath: undefined, runtimeKind: 'PLATFORM_2D' }, files); + await service.uploadSavedMap('token', { ...input, databaseUploadId: undefined }, { + ...files, database: { originalname: 'test.db', buffer: Buffer.from('database') }, + }); + expect(storage.uploadPublicFile).toHaveBeenCalledTimes(5); + expect(storage.verifyUploadedFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/studio/studio.controller.ts b/src/studio/studio.controller.ts index 0ab6128..f832c72 100644 --- a/src/studio/studio.controller.ts +++ b/src/studio/studio.controller.ts @@ -97,7 +97,7 @@ export class StudioController { uploadSavedMap( @Req() request: Request, @UploadedFiles() files: Record>, - @Body() body: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string }, + @Body() body: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string; databaseUploadId?: string }, ) { return this.studioService.uploadSavedMap(this.getBearerToken(request), body, { yaml: files?.yaml?.[0], @@ -106,6 +106,14 @@ export class StudioController { }); } + @Post('maps/database-upload') + prepareDatabaseUpload( + @Req() request: Request, + @Body() body: { courseId?: string; week?: number; databasePath?: string }, + ) { + return this.studioService.prepareDatabaseUpload(this.getBearerToken(request), body); + } + @Delete('maps/:id') deleteSavedMap(@Req() request: Request, @Param('id') id: string) { return this.studioService.deleteSavedMap(this.getBearerToken(request), id); diff --git a/src/studio/studio.service.ts b/src/studio/studio.service.ts index 5d2e689..3242594 100644 --- a/src/studio/studio.service.ts +++ b/src/studio/studio.service.ts @@ -666,9 +666,37 @@ export class StudioService { }); } + async prepareDatabaseUpload( + accessToken: string | undefined, + input: { courseId?: string; week?: number; databasePath?: string }, + ) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + const courseId = input.courseId?.trim(); + const week = Number(input.week); + if (!courseId || !Number.isInteger(week) || week < 1) throw new BadRequestException('courseId and week are required'); + if (!/^\/workspace\/maps\/[A-Za-z0-9_-][A-Za-z0-9._-]*\.db$/.test(input.databasePath ?? '')) { + throw new BadRequestException('databasePath is invalid'); + } + const user = await this.prisma.user.findUnique({ where: { email: authUser.email } }); + if (!user) throw new NotFoundException('LMS user not found'); + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { userId: user.id, courseId, status: 'ACTIVE' }, + }); + if (!enrollment) throw new NotFoundException('Student enrollment not found'); + const databaseUploadId = randomUUID(); + const key = this.r2PublicFileService.createLmsSavedMapFileKey({ + userId: user.id, courseId, week, mapId: databaseUploadId, + fileName: input.databasePath!.split('/').pop()!, + }); + const uploadUrl = await this.r2PublicFileService.createUploadUrl(key); + if (!uploadUrl) throw new BadGatewayException('R2 public storage is not configured'); + return { databaseUploadId, uploadUrl }; + } + async uploadSavedMap( accessToken: string | undefined, - input: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string }, + input: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string; databaseUploadId?: string }, files: { yaml?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer }; pgm?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer }; @@ -701,7 +729,10 @@ export class StudioService { if (!sourceMapKey || !viewerModel) throw new BadRequestException('source map is required'); if (!['SIMULATION', 'PLATFORM_2D', 'PLATFORM_3D'].includes(runtimeKind)) throw new BadRequestException('runtimeKind is invalid'); if (!files.yaml?.buffer || !files.pgm?.buffer) throw new BadRequestException('yaml and pgm files are required'); - if (databasePath && !files.database?.buffer) throw new BadRequestException('database file is required'); + if (databasePath && !files.database?.buffer && !input.databaseUploadId) throw new BadRequestException('database file is required'); + if (input.databaseUploadId && (!databasePath || files.database?.buffer || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.databaseUploadId))) { + throw new BadRequestException('databaseUploadId is invalid'); + } const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } }); if (!lmsUser) throw new NotFoundException('LMS user not found'); @@ -710,6 +741,17 @@ export class StudioService { }); if (!enrollment) throw new NotFoundException('Student enrollment not found'); + // Reconstruct the key from the authenticated owner, never trust a client URL. + let uploadedDatabaseUrl: string | null = null; + if (input.databaseUploadId && databasePath) { + const key = this.r2PublicFileService.createLmsSavedMapFileKey({ + userId: lmsUser.id, courseId, week, mapId: input.databaseUploadId, + fileName: databasePath.split('/').pop()!, + }); + uploadedDatabaseUrl = await this.r2PublicFileService.verifyUploadedFile(key).catch(() => null); + if (!uploadedDatabaseUrl) throw new BadRequestException('Database upload is not complete'); + } + const existing = await this.prisma.studentSavedMap.findUnique({ where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week, yamlPath } }, }); @@ -737,7 +779,7 @@ export class StudioService { body: files.database.buffer, contentType: files.database.mimetype || 'application/octet-stream', }) - : null; + : uploadedDatabaseUrl; if (!yamlUrl || !pgmUrl || (databasePath && !databaseUrl)) { throw new BadGatewayException('R2 saved map upload failed'); }