multiplayer distinction

This commit is contained in:
Shaheed Azaad
2025-07-16 16:46:27 +02:00
parent dc2f68a2b4
commit 02734040cb
9 changed files with 678 additions and 239 deletions

View File

@@ -29,17 +29,22 @@ export const experiment = pgTable('experiment', {
type: experimentTypeEnum('type').notNull()
});
export const participantSession = pgTable('participant_session', {
export const sessionStatusEnum = pgEnum('session_status', ['in progress', 'completed', 'aborted']);
export const experimentSession = pgTable('experiment_session', {
id: text('id').primaryKey(),
experimentId: text('experiment_id')
.notNull()
.references(() => experiment.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull(),
status: sessionStatusEnum('status').notNull(),
externalId: text('external_id'),
ipAddress: text('ip_address'),
userAgent: text('user_agent')
});
export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect;
export type ParticipantSession = typeof participantSession.$inferSelect;
export type Experiment = typeof experiment.$inferSelect;
export type ExperimentSession = typeof experimentSession.$inferSelect;

View File

@@ -0,0 +1,40 @@
import { db } from '$lib/server/db';
import { experimentSession } from '$lib/server/db/schema';
import { json } from '@sveltejs/kit';
import { and, eq, desc, sql } from 'drizzle-orm';
import { z } from 'zod';
const searchParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().default(10)
});
export async function GET({ params, url }) {
const { id } = params;
const searchParams = searchParamsSchema.parse(Object.fromEntries(url.searchParams));
try {
const sessions = await db
.select()
.from(experimentSession)
.where(eq(experimentSession.experimentId, id))
.orderBy(desc(experimentSession.createdAt))
.limit(searchParams.limit)
.offset((searchParams.page - 1) * searchParams.limit);
const total = await db
.select({ count: sql<number>`count(*)` })
.from(experimentSession)
.where(eq(experimentSession.experimentId, id));
return json({
sessions,
total: total[0].count,
page: searchParams.page,
limit: searchParams.limit
});
} catch (e) {
console.error(e);
return json({ error: 'Something went wrong' }, { status: 500 });
}
}

View File

@@ -7,7 +7,8 @@ import { Label } from '$lib/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import FileBrowser from './FileBrowser.svelte';
import FileManager from './FileManager.svelte';
import ExperimentSessions from './ExperimentSessions.svelte';
let experimentId = '';
let experiment: any = null;
@@ -15,192 +16,49 @@ let name = '';
let description = '';
let error = '';
let success = '';
let files: any[] = [];
let uploading = false;
let uploadError = '';
let copied = false;
let origin = '';
let activeTab = 'info';
async function fetchFiles() {
if (!experimentId) return;
console.log('fetchFiles called for experimentId:', experimentId);
const res = await fetch(`/api/experiment/${experimentId}/files`);
if (res.ok) {
const data = await res.json();
console.log('fetchFiles response:', data);
files = data.files;
} else {
console.log('fetchFiles failed:', res.status);
}
}
async function handleFileInput(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) await uploadFiles(input.files);
}
async function uploadFiles(fileList: FileList) {
uploading = true;
uploadError = '';
try {
for (const file of Array.from(fileList)) {
const form = new FormData();
form.append('file', file);
// Use webkitRelativePath if present, else fallback to file.name
form.append('relativePath', file.webkitRelativePath || file.name);
const res = await fetch(`/api/experiment/${experimentId}/files`, {
method: 'POST',
body: form,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Upload failed');
}
}
await fetchFiles();
} catch (e: any) {
uploadError = e.message || 'Upload failed';
} finally {
uploading = false;
}
}
async function deleteFile(key: string) {
// If key contains experiment_files/, strip everything before and including it
let relativeKey = key;
const expFilesIdx = key.indexOf('/experiment_files/');
if (expFilesIdx !== -1) {
relativeKey = key.substring(expFilesIdx + '/experiment_files/'.length);
} else {
// fallback: if key contains experimentId, strip up to and including it
const expIdIdx = key.indexOf(experimentId + '/');
if (expIdIdx !== -1) {
relativeKey = key.substring(expIdIdx + experimentId.length + 1);
}
}
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(relativeKey)}`;
const res = await fetch(url, { method: 'DELETE' });
if (res.ok) await fetchFiles();
}
// Helper: Convert flat file list to tree
function buildFileTree(files: any[]): any {
const root: any = {};
for (const file of files) {
const parts = file.name.split('/');
let node = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (i === parts.length - 1) {
// File
node[part] = { ...file, isFile: true };
} else {
// Folder
node[part] = node[part] || { children: {}, isFile: false };
node = node[part].children;
}
}
}
return root;
}
function downloadFile(key: string) {
// Download file from S3 via API
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}&download=1`;
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Failed to download');
const disposition = res.headers.get('Content-Disposition');
let filename = key.split('/').pop() || 'file';
if (disposition) {
const match = disposition.match(/filename="?([^";]+)"?/);
if (match) filename = match[1];
}
return res.blob().then(blob => ({ blob, filename }));
})
.then(({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(url);
a.remove();
}, 100);
})
.catch(() => alert('Failed to download file.'));
}
let expanded = new Set<string>();
function toggleFolder(path: string) {
if (expanded.has(path)) expanded.delete(path);
else expanded.add(path);
expanded = new Set(expanded); // trigger reactivity
}
$: publicLink = experiment
? `${origin}/public/${experiment.multiplayer ? 'multiplayer/run' : 'run'}/${experiment.id}`
: '';
function copyLink() {
const link = `${origin}/public/run/${experiment.id}`;
navigator.clipboard.writeText(link);
copied = true;
setTimeout(() => (copied = false), 2000);
}
async function deleteFileOrFolder(key: string, isFolder: boolean) {
console.log('deleteFileOrFolder called:', { key, isFolder, experimentId });
if (isFolder) {
// For folders, use the same logic to get the relative prefix
let relativePrefix = key;
const expFilesIdx = key.indexOf('/experiment_files/');
if (expFilesIdx !== -1) {
relativePrefix = key.substring(expFilesIdx + '/experiment_files/'.length);
} else {
const expIdIdx = key.indexOf(experimentId + '/');
if (expIdIdx !== -1) {
relativePrefix = key.substring(expIdIdx + experimentId.length + 1);
}
}
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(relativePrefix)}`;
const res = await fetch(url, { method: 'DELETE' });
if (res.ok) await fetchFiles();
} else {
await deleteFile(key);
}
navigator.clipboard.writeText(publicLink);
copied = true;
setTimeout(() => (copied = false), 2000);
}
onMount(async () => {
origin = window.location.origin;
const params = get(page).params;
experimentId = params.id;
const res = await fetch(`/api/experiment?id=${experimentId}`);
if (res.ok) {
const data = await res.json();
experiment = data.experiment;
console.log(experiment)
name = experiment.name;
description = experiment.description;
} else {
error = 'Failed to load experiment.';
}
await fetchFiles();
origin = window.location.origin;
const params = get(page).params;
experimentId = params.id;
const res = await fetch(`/api/experiment?id=${experimentId}`);
if (res.ok) {
const data = await res.json();
experiment = data.experiment;
console.log(experiment)
name = experiment.name;
description = experiment.description;
} else {
error = 'Failed to load experiment.';
}
});
async function handleSave() {
error = '';
success = '';
const res = await fetch(`/api/experiment?id=${experimentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
});
if (res.ok) {
success = 'Experiment updated!';
} else {
error = 'Failed to update experiment.';
}
error = '';
success = '';
const res = await fetch(`/api/experiment?id=${experimentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
});
if (res.ok) {
success = 'Experiment updated!';
} else {
error = 'Failed to update experiment.';
}
}
</script>
@@ -210,6 +68,7 @@ async function handleSave() {
<div class="flex border-b mb-4">
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'info'} class:border-transparent={activeTab !== 'info'} on:click={() => activeTab = 'info'}>Info</button>
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'files'} class:border-transparent={activeTab !== 'files'} on:click={() => activeTab = 'files'}>Files</button>
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'sessions'} class:border-transparent={activeTab !== 'sessions'} on:click={() => activeTab = 'sessions'}>Sessions</button>
</div>
{#if activeTab === 'info'}
<Card class="w-full">
@@ -253,7 +112,7 @@ async function handleSave() {
<div class="mt-8">
<Label>Public Link</Label>
<div class="flex items-center space-x-2 mt-2">
<Input id="public-link" type="text" readonly value={`${origin}/public/run/${experiment.id}`} />
<Input id="public-link" type="text" readonly value={publicLink} />
<Button on:click={copyLink}>Copy</Button>
</div>
{#if copied}
@@ -262,47 +121,10 @@ async function handleSave() {
</div>
</CardContent>
</Card>
{/if}
{#if activeTab === 'files'}
<Card class="w-full">
<CardHeader>
<CardTitle>Experiment Files</CardTitle>
</CardHeader>
<CardContent>
<div class="mt-8">
<div class="flex justify-end mb-4">
<button
type="button"
class="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition font-medium"
on:click={() => document.getElementById('file-input')?.click()}
>
Upload Files or Folder
</button>
<input id="file-input" type="file" multiple webkitdirectory class="hidden" on:change={handleFileInput} />
</div>
{#if uploading}
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
{/if}
{#if uploadError}
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
{/if}
<ul class="divide-y divide-border">
<FileBrowser
tree={buildFileTree(files)}
parentPath=""
{expanded}
onToggle={toggleFolder}
onDelete={deleteFileOrFolder}
onDownload={downloadFile}
isRoot={true}
/>
{#if files.length === 0}
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
{/if}
</ul>
</div>
</CardContent>
</Card>
{:else if activeTab === 'files'}
<FileManager {experimentId} />
{:else if activeTab === 'sessions'}
<ExperimentSessions {experimentId} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table';
export let experimentId: string;
let sessions: any[] = [];
let page = 1;
let limit = 10;
let total = 0;
let loading = false;
async function fetchSessions() {
loading = true;
const res = await fetch(
`/api/experiment/${experimentId}/sessions?page=${page}&limit=${limit}`
);
if (res.ok) {
const data = await res.json();
sessions = data.sessions;
total = data.total;
}
loading = false;
}
function nextPage() {
if (page * limit < total) {
page++;
fetchSessions();
}
}
function prevPage() {
if (page > 1) {
page--;
fetchSessions();
}
}
onMount(() => {
if (experimentId) {
fetchSessions();
}
});
</script>
<div class="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Updated At</TableHead>
<TableHead>External ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if loading}
<TableRow>
<TableCell colspan={5} class="text-center">Loading...</TableCell>
</TableRow>
{:else if sessions.length === 0}
<TableRow>
<TableCell colspan={5} class="text-center">No sessions found.</TableCell>
</TableRow>
{:else}
{#each sessions as session}
<TableRow>
<TableCell>{session.id}</TableCell>
<TableCell>{session.status}</TableCell>
<TableCell>{new Date(session.createdAt).toLocaleString()}</TableCell>
<TableCell>{new Date(session.updatedAt).toLocaleString()}</TableCell>
<TableCell>{session.externalId || 'N/A'}</TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
<div class="flex justify-between items-center">
<div>
<p class="text-sm text-muted-foreground">
Showing {Math.min((page - 1) * limit + 1, total)} to {Math.min(page * limit, total)} of {total} sessions
</p>
</div>
<div class="flex space-x-2">
<Button on:click={prevPage} disabled={page === 1}>Previous</Button>
<Button on:click={nextPage} disabled={page * limit >= total}>Next</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { onMount } from 'svelte';
import FileBrowser from './FileBrowser.svelte';
export let experimentId: string;
let files: any[] = [];
let uploading = false;
let uploadError = '';
async function fetchFiles() {
if (!experimentId) return;
const res = await fetch(`/api/experiment/${experimentId}/files`);
if (res.ok) {
const data = await res.json();
files = data.files;
}
}
async function handleFileInput(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) await uploadFiles(input.files);
}
async function uploadFiles(fileList: FileList) {
uploading = true;
uploadError = '';
try {
for (const file of Array.from(fileList)) {
const form = new FormData();
form.append('file', file);
form.append('relativePath', file.webkitRelativePath || file.name);
const res = await fetch(`/api/experiment/${experimentId}/files`, {
method: 'POST',
body: form
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Upload failed');
}
}
await fetchFiles();
} catch (e: any) {
uploadError = e.message || 'Upload failed';
} finally {
uploading = false;
}
}
async function deleteFile(key: string) {
let relativeKey = key;
const expFilesIdx = key.indexOf('/experiment_files/');
if (expFilesIdx !== -1) {
relativeKey = key.substring(expFilesIdx + '/experiment_files/'.length);
} else {
const expIdIdx = key.indexOf(experimentId + '/');
if (expIdIdx !== -1) {
relativeKey = key.substring(expIdIdx + experimentId.length + 1);
}
}
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(relativeKey)}`;
const res = await fetch(url, { method: 'DELETE' });
if (res.ok) await fetchFiles();
}
function buildFileTree(files: any[]): any {
const root: any = {};
for (const file of files) {
const parts = file.name.split('/');
let node = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (i === parts.length - 1) {
node[part] = { ...file, isFile: true };
} else {
node[part] = node[part] || { children: {}, isFile: false };
node = node[part].children;
}
}
}
return root;
}
function downloadFile(key: string) {
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}&download=1`;
fetch(url)
.then((res) => {
if (!res.ok) throw new Error('Failed to download');
const disposition = res.headers.get('Content-Disposition');
let filename = key.split('/').pop() || 'file';
if (disposition) {
const match = disposition.match(/filename="?([^";]+)"?/);
if (match) filename = match[1];
}
return res.blob().then((blob) => ({ blob, filename }));
})
.then(({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(url);
a.remove();
}, 100);
})
.catch(() => alert('Failed to download file.'));
}
let expanded = new Set<string>();
function toggleFolder(path: string) {
if (expanded.has(path)) expanded.delete(path);
else expanded.add(path);
expanded = new Set(expanded);
}
async function deleteFileOrFolder(key: string, isFolder: boolean) {
if (isFolder) {
let relativePrefix = key;
const expFilesIdx = key.indexOf('/experiment_files/');
if (expFilesIdx !== -1) {
relativePrefix = key.substring(expFilesIdx + '/experiment_files/'.length);
} else {
const expIdIdx = key.indexOf(experimentId + '/');
if (expIdIdx !== -1) {
relativePrefix = key.substring(expIdIdx + experimentId.length + 1);
}
}
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(
relativePrefix
)}`;
const res = await fetch(url, { method: 'DELETE' });
if (res.ok) await fetchFiles();
} else {
await deleteFile(key);
}
}
onMount(async () => {
await fetchFiles();
});
</script>
<Card class="w-full">
<CardHeader>
<CardTitle>Experiment Files</CardTitle>
</CardHeader>
<CardContent>
<div class="mt-8">
<div class="flex justify-end mb-4">
<Button
type="button"
class="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition font-medium"
on:click={() => document.getElementById('file-input')?.click()}
>
Upload Files or Folder
</Button>
<input
id="file-input"
type="file"
multiple
webkitdirectory
class="hidden"
on:change={handleFileInput}
/>
</div>
{#if uploading}
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
{/if}
{#if uploadError}
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
{/if}
<ul class="divide-y divide-border">
<FileBrowser
tree={buildFileTree(files)}
parentPath=""
{expanded}
onToggle={toggleFolder}
onDelete={deleteFileOrFolder}
onDownload={downloadFile}
isRoot={true}
/>
{#if files.length === 0}
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
{/if}
</ul>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,274 @@
import { db } from '$lib/server/db/index.js';
import * as schema from '$lib/server/db/schema.js';
import s3 from '$lib/server/s3.js';
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import mime from 'mime-types';
import { S3_BUCKET } from '$env/static/private';
import { dev } from '$app/environment';
import { promises as fs } from 'fs';
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
// Rules for proxying vendor files to a CDN using regex to extract versions
const vendorFileRules = [
{
regex: /^lib\/vendors\/jquery-([\d.]+)\.min\.js$/,
url: (version: string) => `https://code.jquery.com/jquery-${version}.min.js`
},
{
regex: /^lib\/vendors\/surveyjs\.jquery-([\d.]+)\.min\.js$/,
url: (version: string) => `https://unpkg.com/survey-jquery@${version}/survey.jquery.min.js`
},
{
regex: /^lib\/vendors\/surveyjs\.defaultV2-([\d.]+)-OST\.min\.css$/,
url: (version: string) => `https://unpkg.com/survey-core@${version}/defaultV2.min.css`
}
];
export async function GET({ params, cookies, getClientAddress, request }) {
const { experimentId, path } = params;
// Check if the experiment is a multiplayer experiment
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw error(404, 'Experiment not found');
}
if (!experiment.multiplayer) {
throw error(403, 'This is not a multiplayer experiment');
}
// Map of requested CSS files to their location in the static directory.
const staticCssMap: Record<string, string> = {
'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css',
'lib/vendors/survey.grey_style.css': 'static/lib/psychoJS/surveyJS/survey.grey_style.css'
};
if (path in staticCssMap) {
const filePath = staticCssMap[path];
try {
const fileContents = await fs.readFile(filePath, 'utf-8');
return new Response(fileContents, {
headers: { 'Content-Type': 'text/css' }
});
} catch (e: any) {
if (e.code === 'ENOENT') {
throw error(404, 'File not found in static directory');
}
throw error(500, 'Error reading static file');
}
}
// Check if the requested path is a vendor file and proxy to CDN
for (const rule of vendorFileRules) {
const match = path.match(rule.regex);
if (match) {
try {
// The first element of match is the full string, subsequent elements are capture groups.
const cdnUrl = (rule.url as Function).apply(null, match.slice(1));
const cdnResponse = await fetch(cdnUrl);
if (!cdnResponse.ok) {
throw error(
cdnResponse.status,
`Failed to fetch from CDN: ${cdnResponse.statusText}`
);
}
// Buffer the entire response to avoid potential streaming issues.
// This is less memory-efficient but more robust for debugging.
const body = await cdnResponse.arrayBuffer();
const headers = new Headers(cdnResponse.headers);
// The `fetch` API automatically decompresses content, so we need to remove
// the Content-Encoding header to avoid the browser trying to decompress it again.
// We also remove Content-Length because it's now incorrect for the decompressed body.
headers.delete('Content-Encoding');
headers.delete('Content-Length');
// Ensure the Content-Type is set correctly, as it's crucial for the browser.
if (!headers.has('Content-Type')) {
headers.set(
'Content-Type',
path.endsWith('.css') ? 'text/css' : 'application/javascript'
);
}
// Forward the response from the CDN
return new Response(body, {
status: cdnResponse.status,
statusText: cdnResponse.statusText,
headers: headers
});
} catch (e: any) {
throw error(500, 'Failed to proxy vendor file');
}
}
}
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
let experimentSessionId = cookies.get(cookieName);
if (!experimentSessionId) {
// First request for this experiment. Create a new participant session.
experimentSessionId = randomUUID();
const now = new Date();
await db.insert(schema.experimentSession).values({
id: experimentSessionId,
experimentId,
createdAt: now,
updatedAt: now,
status: 'in progress',
ipAddress: getClientAddress(),
userAgent: request.headers.get('user-agent') ?? undefined
});
cookies.set(cookieName, experimentSessionId, {
path: `/public/multiplayer/run/${experimentId}`,
httpOnly: true,
secure: !dev,
maxAge: 60 * 60 * 24 * 365 // 1 year
});
} else {
// subsequent requests, check if cookie is valid
const [session] = await db
.select()
.from(schema.experimentSession)
.where(eq(schema.experimentSession.id, experimentSessionId));
if (!session) {
// invalid cookie, create new session
const newExperimentSessionId = randomUUID();
const now = new Date();
await db.insert(schema.experimentSession).values({
id: newExperimentSessionId,
experimentId,
createdAt: now,
updatedAt: now,
status: 'in progress',
ipAddress: getClientAddress(),
userAgent: request.headers.get('user-agent') ?? undefined
});
cookies.set(cookieName, newExperimentSessionId, {
path: `/public/multiplayer/run/${experimentId}`,
httpOnly: true,
secure: !dev,
maxAge: 60 * 60 * 24 * 365 // 1 year
});
}
}
const s3Prefix = `experiments/${experimentId}/`;
const filePath = path === '' ? 'index.html' : path;
const key = `${s3Prefix}${filePath}`;
// Check if user is trying to access files outside of the experiment directory
if (!key.startsWith(s3Prefix)) {
throw error(403, 'Forbidden');
}
try {
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: key
});
const s3Response = await s3.send(command);
if (!s3Response.Body) {
throw error(404, 'Not found');
}
const stream = s3Response.Body;
const contentType =
mime.lookup(filePath) || s3Response.ContentType || 'application/octet-stream';
if (filePath.endsWith('index.html')) {
const body = await stream.transformToString();
// For PsychoJS experiments, we need to set the base href to the experiment root
// so that relative paths like "stimuli/image.jpg" resolve correctly
const basePath = `/public/multiplayer/run/${experimentId}/`;
// Get all files in the experiment directory to create a resource manifest
const listCommand = new ListObjectsV2Command({
Bucket: S3_BUCKET,
Prefix: `experiments/${experimentId}/`
});
const listResponse = await s3.send(listCommand);
// Create resource manifest for PsychoJS
const resources = (listResponse.Contents || [])
.filter(obj => obj.Key && obj.Key !== `experiments/${experimentId}/index.html`)
.map(obj => {
const relativePath = obj.Key!.replace(`experiments/${experimentId}/`, '');
return {
name: relativePath,
path: relativePath
};
});
// Create the resource injection script that runs after PsychoJS loads
const resourceInjectionScript = `
<script>
// Wait for PsychoJS to be available and inject resources
function injectPsychoJSResources() {
const resources = ${JSON.stringify(resources)};
// Check if PsychoJS and its components are available
if (typeof psychoJS !== 'undefined' && psychoJS.serverManager) {
// Add resources to the server manager's resource list
resources.forEach(resource => {
psychoJS.serverManager._resources.set(resource.name, {
name: resource.name,
path: resource.path,
status: psychoJS.serverManager.constructor.ResourceStatus.NOT_DOWNLOADED
});
});
} else {
// PsychoJS not ready yet, try again in 100ms
setTimeout(injectPsychoJSResources, 100);
}
}
// Start trying to inject resources when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectPsychoJSResources);
} else {
injectPsychoJSResources();
}
</script>
`;
const injectedBody = body
.replace(/<head[^>]*>/, `$&<base href="${basePath}">`)
.replace(/<\/body>/, `${resourceInjectionScript}<script>console.log('injection')</script></body>`);
return new Response(injectedBody, {
headers: {
'Content-Type': contentType,
'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString()
}
});
}
const fileBuffer = await stream.transformToByteArray();
return new Response(fileBuffer, {
headers: { 'Content-Type': contentType }
});
} catch (e: any) {
if (e.name === 'NoSuchKey') {
throw error(404, 'File not found');
} else {
throw error(500, `Error fetching from S3: ${e.message}`);
}
}
}

View File

@@ -10,7 +10,7 @@ import { S3_BUCKET } from '$env/static/private';
import { dev } from '$app/environment';
import { promises as fs } from 'fs';
const PARTICIPANT_COOKIE_PREFIX = 'participant-session-';
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
// Rules for proxying vendor files to a CDN using regex to extract versions
const vendorFileRules = [
@@ -99,22 +99,25 @@ export async function GET({ params, cookies, getClientAddress, request }) {
}
}
const cookieName = `${PARTICIPANT_COOKIE_PREFIX}${experimentId}`;
let participantSessionId = cookies.get(cookieName);
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
let experimentSessionId = cookies.get(cookieName);
if (!participantSessionId) {
if (!experimentSessionId) {
// First request for this experiment. Create a new participant session.
participantSessionId = randomUUID();
experimentSessionId = randomUUID();
const now = new Date();
await db.insert(schema.participantSession).values({
id: participantSessionId,
await db.insert(schema.experimentSession).values({
id: experimentSessionId,
experimentId,
createdAt: new Date(),
createdAt: now,
updatedAt: now,
status: 'in progress',
ipAddress: getClientAddress(),
userAgent: request.headers.get('user-agent') ?? undefined
});
cookies.set(cookieName, participantSessionId, {
cookies.set(cookieName, experimentSessionId, {
path: `/public/run/${experimentId}`,
httpOnly: true,
secure: !dev,
@@ -124,19 +127,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
// subsequent requests, check if cookie is valid
const [session] = await db
.select()
.from(schema.participantSession)
.where(eq(schema.participantSession.id, participantSessionId));
.from(schema.experimentSession)
.where(eq(schema.experimentSession.id, experimentSessionId));
if (!session) {
// invalid cookie, create new session
const newParticipantSessionId = randomUUID();
await db.insert(schema.participantSession).values({
id: newParticipantSessionId,
const newExperimentSessionId = randomUUID();
const now = new Date();
await db.insert(schema.experimentSession).values({
id: newExperimentSessionId,
experimentId,
createdAt: new Date(),
createdAt: now,
updatedAt: now,
status: 'in progress',
ipAddress: getClientAddress(),
userAgent: request.headers.get('user-agent') ?? undefined
});
cookies.set(cookieName, newParticipantSessionId, {
cookies.set(cookieName, newExperimentSessionId, {
path: `/public/run/${experimentId}`,
httpOnly: true,
secure: !dev,