multiplayer distinction
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: General structure of the app
|
alwaysApply: true
|
||||||
globs:
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Structure
|
# Structure
|
||||||
|
|
||||||
- Read, but don't edit @README.md to get an idea of how the app should work.
|
- Read, but don't edit @README.md to get an idea of how the app should work.
|
||||||
@@ -10,3 +8,4 @@ globs:
|
|||||||
- Tests should be written as the app is developed.
|
- Tests should be written as the app is developed.
|
||||||
- Use package.json to ensure you're using the correct packages to implement features and not installing redundant packages or reinventing the wheel.
|
- Use package.json to ensure you're using the correct packages to implement features and not installing redundant packages or reinventing the wheel.
|
||||||
- User superforms for all forms
|
- User superforms for all forms
|
||||||
|
- For now, we will keep multiplayer and single player experiment serving, queuing, and session logic completely separate, even if it means overlapping and redundant code. We will optimise later.
|
||||||
8
.cursor/rules/multiplayer.mdc
Normal file
8
.cursor/rules/multiplayer.mdc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
description: Multiplayer experiment implementation
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
- Multiplayer experiments should be 'run' by the app, not on the participant's computers like singleplayer experiments. This enables participants to drop connection and rejoin the session where they left off
|
||||||
|
- All participants should see the same thing at the same time, but *later* I want to give the experimenter the ability to occasionally show participants different things based on a URL variable that this app will inject based on some . So participants will be on the same 'screen' but with different images, for example. The ability for experimenters to show different things should not be implemented unless directly requested, but any solutions to the basic multiplayer implementation consider that this needs to be possible in the future.
|
||||||
@@ -29,17 +29,22 @@ export const experiment = pgTable('experiment', {
|
|||||||
type: experimentTypeEnum('type').notNull()
|
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(),
|
id: text('id').primaryKey(),
|
||||||
experimentId: text('experiment_id')
|
experimentId: text('experiment_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => experiment.id, { onDelete: 'cascade' }),
|
.references(() => experiment.id, { onDelete: 'cascade' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
|
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'),
|
ipAddress: text('ip_address'),
|
||||||
userAgent: text('user_agent')
|
userAgent: text('user_agent')
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Session = typeof session.$inferSelect;
|
export type Session = typeof session.$inferSelect;
|
||||||
export type User = typeof user.$inferSelect;
|
export type User = typeof user.$inferSelect;
|
||||||
export type ParticipantSession = typeof participantSession.$inferSelect;
|
|
||||||
export type Experiment = typeof experiment.$inferSelect;
|
export type Experiment = typeof experiment.$inferSelect;
|
||||||
|
export type ExperimentSession = typeof experimentSession.$inferSelect;
|
||||||
|
|||||||
40
src/routes/api/experiment/[id]/sessions/+server.ts
Normal file
40
src/routes/api/experiment/[id]/sessions/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import { Label } from '$lib/components/ui/label';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import FileBrowser from './FileBrowser.svelte';
|
import FileManager from './FileManager.svelte';
|
||||||
|
import ExperimentSessions from './ExperimentSessions.svelte';
|
||||||
|
|
||||||
let experimentId = '';
|
let experimentId = '';
|
||||||
let experiment: any = null;
|
let experiment: any = null;
|
||||||
@@ -15,162 +16,20 @@ let name = '';
|
|||||||
let description = '';
|
let description = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let success = '';
|
let success = '';
|
||||||
let files: any[] = [];
|
|
||||||
let uploading = false;
|
|
||||||
let uploadError = '';
|
|
||||||
let copied = false;
|
let copied = false;
|
||||||
let origin = '';
|
let origin = '';
|
||||||
let activeTab = 'info';
|
let activeTab = 'info';
|
||||||
|
|
||||||
async function fetchFiles() {
|
$: publicLink = experiment
|
||||||
if (!experimentId) return;
|
? `${origin}/public/${experiment.multiplayer ? 'multiplayer/run' : 'run'}/${experiment.id}`
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyLink() {
|
function copyLink() {
|
||||||
const link = `${origin}/public/run/${experiment.id}`;
|
navigator.clipboard.writeText(publicLink);
|
||||||
navigator.clipboard.writeText(link);
|
|
||||||
copied = true;
|
copied = true;
|
||||||
setTimeout(() => (copied = false), 2000);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
origin = window.location.origin;
|
origin = window.location.origin;
|
||||||
const params = get(page).params;
|
const params = get(page).params;
|
||||||
@@ -185,7 +44,6 @@ onMount(async () => {
|
|||||||
} else {
|
} else {
|
||||||
error = 'Failed to load experiment.';
|
error = 'Failed to load experiment.';
|
||||||
}
|
}
|
||||||
await fetchFiles();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -210,6 +68,7 @@ async function handleSave() {
|
|||||||
<div class="flex border-b mb-4">
|
<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 === '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 === '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>
|
</div>
|
||||||
{#if activeTab === 'info'}
|
{#if activeTab === 'info'}
|
||||||
<Card class="w-full">
|
<Card class="w-full">
|
||||||
@@ -253,7 +112,7 @@ async function handleSave() {
|
|||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<Label>Public Link</Label>
|
<Label>Public Link</Label>
|
||||||
<div class="flex items-center space-x-2 mt-2">
|
<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>
|
<Button on:click={copyLink}>Copy</Button>
|
||||||
</div>
|
</div>
|
||||||
{#if copied}
|
{#if copied}
|
||||||
@@ -262,47 +121,10 @@ async function handleSave() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{:else if activeTab === 'files'}
|
||||||
{#if activeTab === 'files'}
|
<FileManager {experimentId} />
|
||||||
<Card class="w-full">
|
{:else if activeTab === 'sessions'}
|
||||||
<CardHeader>
|
<ExperimentSessions {experimentId} />
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
92
src/routes/experiment/[id]/ExperimentSessions.svelte
Normal file
92
src/routes/experiment/[id]/ExperimentSessions.svelte
Normal 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>
|
||||||
193
src/routes/experiment/[id]/FileManager.svelte
Normal file
193
src/routes/experiment/[id]/FileManager.svelte
Normal 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>
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { S3_BUCKET } from '$env/static/private';
|
|||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
import { promises as fs } from 'fs';
|
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
|
// Rules for proxying vendor files to a CDN using regex to extract versions
|
||||||
const vendorFileRules = [
|
const vendorFileRules = [
|
||||||
@@ -99,22 +99,25 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieName = `${PARTICIPANT_COOKIE_PREFIX}${experimentId}`;
|
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
|
||||||
let participantSessionId = cookies.get(cookieName);
|
let experimentSessionId = cookies.get(cookieName);
|
||||||
|
|
||||||
if (!participantSessionId) {
|
if (!experimentSessionId) {
|
||||||
// First request for this experiment. Create a new participant session.
|
// First request for this experiment. Create a new participant session.
|
||||||
participantSessionId = randomUUID();
|
experimentSessionId = randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
await db.insert(schema.participantSession).values({
|
await db.insert(schema.experimentSession).values({
|
||||||
id: participantSessionId,
|
id: experimentSessionId,
|
||||||
experimentId,
|
experimentId,
|
||||||
createdAt: new Date(),
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
status: 'in progress',
|
||||||
ipAddress: getClientAddress(),
|
ipAddress: getClientAddress(),
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined
|
userAgent: request.headers.get('user-agent') ?? undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
cookies.set(cookieName, participantSessionId, {
|
cookies.set(cookieName, experimentSessionId, {
|
||||||
path: `/public/run/${experimentId}`,
|
path: `/public/run/${experimentId}`,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !dev,
|
secure: !dev,
|
||||||
@@ -124,19 +127,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
|||||||
// subsequent requests, check if cookie is valid
|
// subsequent requests, check if cookie is valid
|
||||||
const [session] = await db
|
const [session] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.participantSession)
|
.from(schema.experimentSession)
|
||||||
.where(eq(schema.participantSession.id, participantSessionId));
|
.where(eq(schema.experimentSession.id, experimentSessionId));
|
||||||
if (!session) {
|
if (!session) {
|
||||||
// invalid cookie, create new session
|
// invalid cookie, create new session
|
||||||
const newParticipantSessionId = randomUUID();
|
const newExperimentSessionId = randomUUID();
|
||||||
await db.insert(schema.participantSession).values({
|
const now = new Date();
|
||||||
id: newParticipantSessionId,
|
await db.insert(schema.experimentSession).values({
|
||||||
|
id: newExperimentSessionId,
|
||||||
experimentId,
|
experimentId,
|
||||||
createdAt: new Date(),
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
status: 'in progress',
|
||||||
ipAddress: getClientAddress(),
|
ipAddress: getClientAddress(),
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined
|
userAgent: request.headers.get('user-agent') ?? undefined
|
||||||
});
|
});
|
||||||
cookies.set(cookieName, newParticipantSessionId, {
|
cookies.set(cookieName, newExperimentSessionId, {
|
||||||
path: `/public/run/${experimentId}`,
|
path: `/public/run/${experimentId}`,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !dev,
|
secure: !dev,
|
||||||
|
|||||||
Reference in New Issue
Block a user