Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions infra/smalruby-classroom/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ const CLASSROOM_TTL_SECONDS = CLASSROOM_TTL_DAYS * 24 * 60 * 60;
// Session and membership TTL matches classroom TTL
const SESSION_TTL_SECONDS = CLASSROOM_TTL_SECONDS;
// Rate limiting for join endpoint (per IP)
const JOIN_RATE_LIMIT_WINDOW_SECONDS = 60;
const JOIN_RATE_LIMIT_MAX_ATTEMPTS = 50;
const JOIN_RATE_LIMIT_WINDOW_SECONDS = parseInt(process.env.JOIN_RATE_LIMIT_WINDOW_SECONDS || '60', 10);
const JOIN_RATE_LIMIT_MAX_ATTEMPTS = parseInt(process.env.JOIN_RATE_LIMIT_MAX_ATTEMPTS || '50', 10);
const JOIN_CODE_REGEX = new RegExp(`^[${JOIN_CODE_CHARS}]{${JOIN_CODE_LENGTH}}$`);
// Session activity TTL — determines "seated" status for teachers (default 1 hour)
const SESSION_ACTIVE_TTL_SECONDS = parseInt(process.env.SESSION_ACTIVE_TTL_SECONDS || '3600', 10);
// Submission config (TTL matches classroom TTL)
const SUBMISSION_TTL_SECONDS = CLASSROOM_TTL_SECONDS;
const MAX_PROJECT_NAME_LENGTH = 100;
const PRESIGNED_URL_UPLOAD_EXPIRY = 15 * 60; // 15 minutes
const PRESIGNED_URL_DOWNLOAD_EXPIRY = 60 * 60; // 1 hour
const PRESIGNED_URL_UPLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_UPLOAD_EXPIRY || '900', 10); // default 15 minutes
const PRESIGNED_URL_DOWNLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_DOWNLOAD_EXPIRY || '3600', 10); // default 1 hour
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_SCREENSHOT_COUNT = 20;
const MAX_TEACHER_COMMENT_LENGTH = 500;
Expand Down Expand Up @@ -420,6 +422,7 @@ async function handleJoinClassroom(sourceIp: string, body: Record<string, unknow
role: 'student',
sessionToken,
joinedAt: now,
lastActiveAt: now,
ttl: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
},
ConditionExpression: 'attribute_not_exists(classroomId) AND attribute_not_exists(memberId)',
Expand Down Expand Up @@ -486,6 +489,7 @@ async function handleListMembers(teacherSub: string, classroomId: string): Promi
displayName: item.displayName,
role: item.role,
joinedAt: item.joinedAt,
lastActiveAt: item.lastActiveAt || null,
hasSubmission: !!submission,
submissionStatus: submission?.status || null,
submittedAt: submission?.submittedAt || null,
Expand Down Expand Up @@ -887,8 +891,46 @@ async function handleUpdateSubmission(

async function handleVerifySession(sessionToken: string): Promise<APIGatewayProxyStructuredResultV2> {
// verifySessionToken will throw AuthError if invalid
await verifySessionToken(sessionToken);
return { statusCode: 200, body: JSON.stringify({ valid: true }) };
const session = await verifySessionToken(sessionToken);

// Update lastActiveAt and extend TTL on each verify call
const now = new Date().toISOString();
await docClient.send(new UpdateCommand({
TableName: MEMBERSHIPS_TABLE,
Key: { classroomId: session.classroomId, memberId: session.memberId },
UpdateExpression: 'SET lastActiveAt = :now, ttl = :ttl',
ExpressionAttributeValues: {
':now': now,
':ttl': Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
},
}));

// Look up latest submission for this member
let submission: Record<string, unknown> | null = null;
const subResult = await docClient.send(new QueryCommand({
TableName: SUBMISSIONS_TABLE,
IndexName: 'classroomId-memberId-index',
KeyConditionExpression: 'classroomId = :cid AND memberId = :mid',
ExpressionAttributeValues: {
':cid': session.classroomId,
':mid': session.memberId,
},
ScanIndexForward: false,
Limit: 1,
}));
if (subResult.Items && subResult.Items.length > 0) {
const item = subResult.Items[0];
submission = {
status: item.status,
submittedAt: item.submittedAt,
teacherComment: item.teacherComment || null,
};
}

return {
statusCode: 200,
body: JSON.stringify({ valid: true, submission }),
};
}

// --- Main handler ---
Expand Down Expand Up @@ -957,11 +999,27 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGateway
result = await handleListMembers(teacherSub, classroomId);

} else if (method === 'DELETE' && /^\/classrooms\/[^/]+\/members\/[^/]+$/.test(path)) {
const token = extractBearerToken(event.headers?.authorization);
const teacherSub = await verifyGoogleIdToken(token);
const classroomId = event.pathParameters?.classroomId || '';
const memberId = event.pathParameters?.memberId || '';
result = await handleDeleteMember(teacherSub, classroomId, memberId);

if (memberId === 'me') {
// Student self-removal via sessionToken
const token = extractBearerToken(event.headers?.authorization);
const session = await verifySessionToken(token);
if (session.classroomId !== classroomId) {
throw new AuthError('Session does not match this classroom');
}
await docClient.send(new DeleteCommand({
TableName: MEMBERSHIPS_TABLE,
Key: { classroomId, memberId: session.memberId },
}));
result = { statusCode: 204 };
} else {
// Teacher removal via Google ID token
const token = extractBearerToken(event.headers?.authorization);
const teacherSub = await verifyGoogleIdToken(token);
result = await handleDeleteMember(teacherSub, classroomId, memberId);
}

} else if (method === 'POST' && /^\/classrooms\/[^/]+\/submissions$/.test(path)) {
const token = extractBearerToken(event.headers?.authorization);
Expand Down
5 changes: 5 additions & 0 deletions infra/smalruby-classroom/lib/classroom-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ export class ClassroomStack extends cdk.Stack {
GOOGLE_CLIENT_ID: googleClientId,
CORS_ALLOWED_ORIGINS: corsOriginsEnv,
CLASSROOM_TTL_DAYS: String(classroomTtlDays),
SESSION_ACTIVE_TTL_SECONDS: process.env.SESSION_ACTIVE_TTL_SECONDS || '3600',
PRESIGNED_URL_UPLOAD_EXPIRY: process.env.PRESIGNED_URL_UPLOAD_EXPIRY || '900',
PRESIGNED_URL_DOWNLOAD_EXPIRY: process.env.PRESIGNED_URL_DOWNLOAD_EXPIRY || '3600',
JOIN_RATE_LIMIT_WINDOW_SECONDS: process.env.JOIN_RATE_LIMIT_WINDOW_SECONDS || '60',
JOIN_RATE_LIMIT_MAX_ATTEMPTS: process.env.JOIN_RATE_LIMIT_MAX_ATTEMPTS || '50',
STAGE: stage,
},
bundling: {
Expand Down
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,8 @@
"npm": "10.9.5",
"serialport": "^13.0.0",
"ts-node": "10.9.2"
},
"dependencies": {
"jszip": "^3.10.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,24 @@
margin-left: 0.5rem;
}

.seated-badge {
color: #4c97ff;
font-size: 0.7rem;
margin-left: 0.5rem;
font-weight: bold;
}

.not-seated-badge {
color: #aaa;
font-size: 0.7rem;
margin-left: 0.5rem;
}

.seated-label {
text-decoration: underline;
text-underline-offset: 2px;
}

.image-carousel {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -527,6 +545,44 @@
background-color: #e67e16;
}

.refresh-button {
background: none;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0.1rem 0.4rem;
cursor: pointer;
font-size: 0.9rem;
color: #575e75;
margin-left: auto;
}

.refresh-button:hover:not(:disabled) {
background-color: #f0f0f0;
}

.refresh-button:disabled {
opacity: 0.3;
cursor: default;
}

.teacher-comment-box {
background-color: #fff8e1;
border: 1px solid #ffcc02;
border-radius: 4px;
padding: 0.5rem;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}

.teacher-comment-text {
font-size: 0.85rem;
color: #575e75;
white-space: pre-wrap;
word-break: break-word;
}

.submission-detail {
display: flex;
gap: 0.5rem;
Expand Down Expand Up @@ -638,6 +694,12 @@
border-top: 1px solid #e0e0e0;
}

.detail-footer-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}

.delete-confirm-box {
background-color: #fff0f3;
border: 1px solid #ff6680;
Expand Down
Loading
Loading