fix(lms): support direct R2 map uploads and preserve empty course groups

This commit is contained in:
2026-09-09 13:57:28 +09:00
parent 121bf41266
commit d70f99e9c9
12 changed files with 348 additions and 57 deletions
+53 -35
View File
@@ -10,6 +10,7 @@
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1081.0", "@aws-sdk/client-s3": "^3.1081.0",
"@aws-sdk/s3-request-presigner": "^3.1128.0",
"@nestjs/common": "^11.1.24", "@nestjs/common": "^11.1.24",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.24", "@nestjs/core": "^11.1.24",
@@ -208,17 +209,17 @@
} }
}, },
"node_modules/@aws-sdk/core": { "node_modules/@aws-sdk/core": {
"version": "3.974.29", "version": "3.977.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.29.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz",
"integrity": "sha512-yqKcltLbtRh1ubzhRSldIs8jFHNZlyMlgoIccCC0aDVbrB99nXaBdmfr89mK7obWX/NVg4rAMpCpZ6dCDiVBtA==", "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "^3.973.15", "@aws-sdk/types": "^3.974.5",
"@aws-sdk/xml-builder": "^3.972.33", "@aws-sdk/xml-builder": "^3.972.40",
"@aws/lambda-invoke-store": "^0.3.0", "@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.29.0", "@smithy/core": "^3.33.3",
"@smithy/signature-v4": "^5.6.1", "@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.15.1", "@smithy/types": "^4.17.2",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -410,15 +411,32 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@aws-sdk/signature-v4-multi-region": { "node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.996.38", "version": "3.1128.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1128.0.tgz",
"integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", "integrity": "sha512-0wTgUw97lUKx94e4liC53V5z9giwqMaPsFjbXXy8qLC9Q/AaxUySmri+NFkHaOAd908sGsZ6ERMyFO4WMvYxLQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "^3.973.15", "@aws-sdk/core": "^3.977.9",
"@smithy/signature-v4": "^5.6.1", "@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@smithy/types": "^4.15.1", "@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" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -443,12 +461,12 @@
} }
}, },
"node_modules/@aws-sdk/types": { "node_modules/@aws-sdk/types": {
"version": "3.973.15", "version": "3.974.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz",
"integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/types": "^4.15.1", "@smithy/types": "^4.17.2",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -456,12 +474,12 @@
} }
}, },
"node_modules/@aws-sdk/xml-builder": { "node_modules/@aws-sdk/xml-builder": {
"version": "3.972.33", "version": "3.972.40",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
"integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/types": "^4.15.1", "@smithy/types": "^4.17.2",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -2915,12 +2933,12 @@
} }
}, },
"node_modules/@smithy/core": { "node_modules/@smithy/core": {
"version": "3.29.1", "version": "3.33.3",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz",
"integrity": "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==", "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/types": "^4.15.1", "@smithy/types": "^4.17.2",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -2970,13 +2988,13 @@
} }
}, },
"node_modules/@smithy/signature-v4": { "node_modules/@smithy/signature-v4": {
"version": "5.6.2", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
"integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.1", "@smithy/core": "^3.33.3",
"@smithy/types": "^4.15.1", "@smithy/types": "^4.17.2",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -2984,9 +3002,9 @@
} }
}, },
"node_modules/@smithy/types": { "node_modules/@smithy/types": {
"version": "4.15.1", "version": "4.18.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz",
"integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.6.2" "tslib": "^2.6.2"
+1
View File
@@ -21,6 +21,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1081.0", "@aws-sdk/client-s3": "^3.1081.0",
"@aws-sdk/s3-request-presigner": "^3.1128.0",
"@nestjs/common": "^11.1.24", "@nestjs/common": "^11.1.24",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.24", "@nestjs/core": "^11.1.24",
@@ -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";
+4
View File
@@ -65,6 +65,8 @@ model StudentEnrollment {
model StudentGroup { model StudentGroup {
id String @id @default(uuid()) @db.Uuid 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 organizationId String? @map("organization_id") @db.Uuid
organizationName String @map("organization_name") organizationName String @map("organization_name")
name String name String
@@ -84,6 +86,7 @@ model StudentGroup {
teamCustomModules TeamCustomModule[] teamCustomModules TeamCustomModule[]
@@unique([organizationName, name]) @@unique([organizationName, name])
@@index([courseId])
@@index([organizationId]) @@index([organizationId])
@@index([organizationName]) @@index([organizationName])
@@index([instructorPlatformId]) @@index([instructorPlatformId])
@@ -168,6 +171,7 @@ model Course {
customModules StudentCustomModule[] customModules StudentCustomModule[]
teamCustomModules TeamCustomModule[] teamCustomModules TeamCustomModule[]
submissionScores SubmissionScore[] submissionScores SubmissionScore[]
studentGroups StudentGroup[]
submissionTotalScores SubmissionTotalScore[] submissionTotalScores SubmissionTotalScore[]
@@index([isActive]) @@index([isActive])
+71
View File
@@ -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<void>) => 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);
});
});
+14
View File
@@ -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 } } } } },
},
],
};
}
+19 -17
View File
@@ -22,6 +22,7 @@ import { R2PublicFileService } from '../storage/r2-public-file.service';
import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto'; import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto';
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
import { groupCourseScope } from './group-course-scope';
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto'; import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto'; import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
@@ -1126,6 +1127,8 @@ export class InstructorService {
? { courseId: course.id } ? { courseId: course.id }
: { course: { teacherId: instructor.id } }; : { course: { teacherId: instructor.id } };
if (courseId?.trim() && !course) throw new NotFoundException('Course not found');
const groups = await this.prisma.studentGroup.findMany({ const groups = await this.prisma.studentGroup.findMany({
where: { where: {
AND: [ AND: [
@@ -1135,20 +1138,7 @@ export class InstructorService {
organizationName ? { organizationName } : undefined, organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[], ].filter(Boolean) as Prisma.StudentGroupWhereInput[],
}, },
{ groupCourseScope(instructor.id, course?.id),
members: {
some: {
user: {
enrollments: {
some: {
status: 'ACTIVE',
...enrollmentScope,
},
},
},
},
},
},
], ],
}, },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
@@ -2568,6 +2558,9 @@ export class InstructorService {
const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required'); 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(); const name = input.name.trim();
if (!name) throw new BadRequestException('Group name is required'); if (!name) throw new BadRequestException('Group name is required');
@@ -2584,6 +2577,7 @@ export class InstructorService {
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const group = await tx.studentGroup.create({ const group = await tx.studentGroup.create({
data: { data: {
courseId: course.id,
organizationId, organizationId,
organizationName, organizationName,
name, name,
@@ -2637,7 +2631,9 @@ export class InstructorService {
const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required'); 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(); const name = input.name.trim();
if (!name) throw new BadRequestException('Group name is required'); if (!name) throw new BadRequestException('Group name is required');
const memberIds = await this.validateActiveStudentIds( const memberIds = await this.validateActiveStudentIds(
@@ -2665,6 +2661,7 @@ export class InstructorService {
await tx.studentGroup.update({ await tx.studentGroup.update({
where: { id: group.id }, where: { id: group.id },
data: { data: {
courseId: course.id,
name, name,
instructorPlatformId: assignedPlatform?.id ?? null, instructorPlatformId: assignedPlatform?.id ?? null,
platformName: assignedPlatform?.displayName ?? null, platformName: assignedPlatform?.displayName ?? null,
@@ -2734,12 +2731,14 @@ export class InstructorService {
} }
async deleteGroup(accessToken: string | undefined, groupId: string, courseId?: string | null) { 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 organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required'); 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 previousAssignment = await this.getGroupPlatformAssignment(group.id);
const termination = await this.prepareGroupPlatformTermination({ const termination = await this.prepareGroupPlatformTermination({
groupId: group.id, groupId: group.id,
@@ -3004,10 +3003,13 @@ export class InstructorService {
groupId: string, groupId: string,
organizationId: string | undefined, organizationId: string | undefined,
organizationName: string, organizationName: string,
instructorId: string,
courseId: string,
) { ) {
const group = await this.prisma.studentGroup.findFirst({ const group = await this.prisma.studentGroup.findFirst({
where: { where: {
id: groupId, id: groupId,
AND: [groupCourseScope(instructorId, courseId)],
OR: [ OR: [
organizationId ? { organizationId } : undefined, organizationId ? { organizationId } : undefined,
{ organizationName }, { organizationName },
@@ -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();
});
});
+19 -1
View File
@@ -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 { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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) { publicUrl(key: string) {
if (!this.publicBaseUrl || !this.isPublicKey(key)) return null; if (!this.publicBaseUrl || !this.isPublicKey(key)) return null;
return `${this.publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`; return `${this.publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`;
@@ -109,6 +125,8 @@ export class R2PublicFileService {
secretAccessKey: this.secretAccessKey, secretAccessKey: this.secretAccessKey,
}, },
forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false', forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false',
// Presigned PUT bodies are supplied later by the platform, not this client.
requestChecksumCalculation: 'WHEN_REQUIRED',
}); });
return this.client; return this.client;
+76
View File
@@ -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();
});
});
+9 -1
View File
@@ -97,7 +97,7 @@ export class StudioController {
uploadSavedMap( uploadSavedMap(
@Req() request: Request, @Req() request: Request,
@UploadedFiles() files: Record<string, Array<{ originalname: string; mimetype?: string; size?: number; buffer?: Buffer }>>, @UploadedFiles() files: Record<string, Array<{ originalname: string; mimetype?: string; size?: number; buffer?: Buffer }>>,
@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, { return this.studioService.uploadSavedMap(this.getBearerToken(request), body, {
yaml: files?.yaml?.[0], 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') @Delete('maps/:id')
deleteSavedMap(@Req() request: Request, @Param('id') id: string) { deleteSavedMap(@Req() request: Request, @Param('id') id: string) {
return this.studioService.deleteSavedMap(this.getBearerToken(request), id); return this.studioService.deleteSavedMap(this.getBearerToken(request), id);
+45 -3
View File
@@ -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( async uploadSavedMap(
accessToken: string | undefined, 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: { files: {
yaml?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer }; yaml?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer };
pgm?: { 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 (!sourceMapKey || !viewerModel) throw new BadRequestException('source map is required');
if (!['SIMULATION', 'PLATFORM_2D', 'PLATFORM_3D'].includes(runtimeKind)) throw new BadRequestException('runtimeKind is invalid'); 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 (!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 } }); const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } });
if (!lmsUser) throw new NotFoundException('LMS user not found'); 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'); 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({ const existing = await this.prisma.studentSavedMap.findUnique({
where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week, yamlPath } }, where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week, yamlPath } },
}); });
@@ -737,7 +779,7 @@ export class StudioService {
body: files.database.buffer, body: files.database.buffer,
contentType: files.database.mimetype || 'application/octet-stream', contentType: files.database.mimetype || 'application/octet-stream',
}) })
: null; : uploadedDatabaseUrl;
if (!yamlUrl || !pgmUrl || (databasePath && !databaseUrl)) { if (!yamlUrl || !pgmUrl || (databasePath && !databaseUrl)) {
throw new BadGatewayException('R2 saved map upload failed'); throw new BadGatewayException('R2 saved map upload failed');
} }