diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..9531c27 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,30 @@ +import getAccessTokenDispatcher from "./getAccessToken"; + +// test data: 6697e96f70b84092deb6132b, 61381e32f27ab4fbed1ec26e +export async function authSubject(request, env, ctx) { + const headers = request.headers; + const studyId = headers.get('x-study-id'); + const subjectId = headers.get('x-subject-id'); + const prolificApiKey = env.PROLIFIC_API_KEY; + + if (!studyId || !subjectId || !subjectId.match(/^[a-zA-Z0-9]{1,32}$/)) + return false; + + try { + const apiUrl = `https://api.prolific.com/api/v1/studies/${studyId}/submissions/`; + const res = await fetch(apiUrl, { + headers: { Authorization: `Token ${prolificApiKey}` } + }); + let data = await res.json(); + return Array.from(data.results).some(p => p.participant_id === subjectId); + + } catch (e) { console.log(e); return false; } +} + +export async function authDispatcher(request, env, ctx) { + if (await authSubject(request, env, ctx)) { + return await getAccessTokenDispatcher(request, env, ctx); + } else return new Response(null, { status: 401, statusText: 'Unauthorized' }); +} + +export default authDispatcher; \ No newline at end of file diff --git a/src/download.js b/src/download.js new file mode 100644 index 0000000..41f0da2 --- /dev/null +++ b/src/download.js @@ -0,0 +1,27 @@ +export async function downloadDispatcher(request, env, ctx) { + const url = new URL(request.url); + const uri = url.pathname; + + const bucket = env.EXP_DATA; + const adminSecret = env.ADMIN_SECRET; + + if (request.headers.get('x-access-token') !== adminSecret) + return new Response(null, { status: 401, statusText: 'Unauthorized' }); + try { + const key = uri.slice(10); // '/download/'.length + const object = await bucket.get(key); + if (object === null) + return new Response(null, { status: 404, statusText: 'Not Found' }); + + const { body, etag, httpMetadata, customMetadata } = object; + return new Response(body, { headers: { + etag: etag, + 'content-type': httpMetadata.contentType, + 'content-length': body.length, + 'x-metadata': JSON.stringify(customMetadata), + }}); + + } catch (e) { return new Response(null, { status: 400 }); } +} + +export default downloadDispatcher; \ No newline at end of file diff --git a/src/getAccessToken.js b/src/getAccessToken.js new file mode 100644 index 0000000..7fcfb21 --- /dev/null +++ b/src/getAccessToken.js @@ -0,0 +1,29 @@ +import { getHashString } from './utils.js' + +export async function getAccessToken(subid, timestamp, secretBase) { + return await getHashString(`${subid}${timestamp}${secretBase}`); +} + +export async function verifyAccessToken(request, env, ctx) { + const headers = request.headers; + const subid = headers.get('x-subject-id'); + const timestamp = headers.get('x-timestamp'); + const token = headers.get('x-access-token'); + const secretBase = env.ACCESS_TOKEN_BASE; + return token === await getAccessToken(subid, timestamp, secretBase); +} + +export async function getAccessTokenDispatcher(request, env, ctx) { + const subid = request.headers.get('x-subject-id'); + if (subid === null) + return new Response(null, { status: 400 }); + + const timestamp = new Date().toISOString().replace(/:/g, '_'); + const secretBase = env.ACCESS_TOKEN_BASE; + + const token = await getAccessToken(subid, timestamp, secretBase); + const res = JSON.stringify({ token, timestamp }); + return new Response(res, { status: 200 }); +} + +export default getAccessTokenDispatcher; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 8b93fed..3389c5a 100644 --- a/src/index.js +++ b/src/index.js @@ -7,17 +7,42 @@ * * Learn more at https://developers.cloudflare.com/workers/ */ - -async function getHashString(string, method = 'SHA-256') { - const encoder = new TextEncoder(); - const data = encoder.encode(string); - const hash = await crypto.subtle.digest(method, data); - const hashArray = Array.from(new Uint8Array(hash)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); -} +import getAccessTokenDispatcher from './getAccessToken.js'; +import authDispatcher from './auth.js'; +import listDispatcher from './list.js'; +import downloadDispatcher from './download.js'; +import putDispatcher from './put.js'; export default { async fetch(request, env, ctx) { - return new Response('Hello World!'); + const url = new URL(request.url); + const uri = url.pathname; + if (url.hostname !== '127.0.0.1' && url.protocol === 'http:') { + return new Response(null, { status: 301, headers: { + Location: url.toString().replace(/^(http:)/, 'https:') + }}); + } + + if (request.method === 'GET' && uri === '/getAccessToken') { + return await getAccessTokenDispatcher(request, env, ctx); + } + + if (request.method === 'GET' && uri === '/auth') { + return await authDispatcher(request, env, ctx); + } + + if (request.method === 'GET' && uri === '/list') { + return await listDispatcher(request, env, ctx); + } + + if (request.method === 'GET' && uri.startsWith('/download/')) { + return await downloadDispatcher(request, env, ctx); + } + + if (request.method === 'PUT') { + return await putDispatcher(request, env, ctx); + } + + return new Response('', { status: 200 }); }, }; diff --git a/src/list.js b/src/list.js new file mode 100644 index 0000000..41792c4 --- /dev/null +++ b/src/list.js @@ -0,0 +1,48 @@ +export async function listDispatcher(request, env, ctx) { + const bucket = env.EXP_DATA; + const adminSecret = env.ADMIN_SECRET; + const badRequest = new Response(null, { status: 400 }); + + if (request.headers.get('x-access-token') !== adminSecret) + return new Response(null, { status: 401, statusText: 'Unauthorized' }); + try { + const type = request.headers.get('x-list-type') || 'prefix'; + let pattern = request.headers.get('x-pattern') || ''; + + let objects = []; + let truncated = true; + let cursor = ""; + switch (type) { + case 'prefix': + while (truncated) { + const res = await bucket.list({ prefix: pattern, cursor }); + objects = objects.concat(res.objects); + truncated = res.truncated; + cursor = res.cursor; + } + break; + case 'suffix': + pattern += '$'; + case 'regex': + while (truncated) { + const res = await bucket.list({ cursor: cursor }); + objects = objects.concat(res.objects); + truncated = res.truncated; + cursor = res.cursor; + } + pattern = new RegExp(pattern); + objects = objects.filter(obj => obj.key.match(pattern)); + break; + default: + return badRequest; + } + + const res = JSON.stringify(objects, ['key', 'etag', 'size', 'uploaded']); + return new Response(res, { status: 200 }); + } catch (e) { + console.error(e); + return badRequest; + } +} + +export default listDispatcher; \ No newline at end of file diff --git a/src/put.js b/src/put.js new file mode 100644 index 0000000..6a0d372 --- /dev/null +++ b/src/put.js @@ -0,0 +1,53 @@ +import { verifyAccessToken } from "./getAccessToken"; + +const allowedContentTypes = [ + 'text/plain', + 'text/csv', +]; + +async function invalidPutRequest(request, env, ctx) { + const headers = request.headers; + const subid = headers.get('x-subject-id'); + const contentType = headers.get('content-type'); + + return (subid === null) + || (!subid.match(/^[a-zA-Z0-9]{1,32}$/)) + || (!await verifyAccessToken(request, env, ctx)) + || (!allowedContentTypes.includes(contentType)); +} + +export async function putDispatcher(request, env, ctx) { + const url = new URL(request.url); + const uri = url.pathname; + const headers = request.headers; + + const bucket = env.EXP_DATA; + const keyPrefix = env.KEY_PREFIX; + const timestamp = new Date().toISOString().replace(/:/g, '_'); + + try { + if (await invalidPutRequest(request, env, ctx)) + return new Response(null, { status: 401, statusText: 'Unauthorized' }); + + const subid = headers.get('x-subject-id'); + const data = await request.text(); + const objectKey = `${subid}${uri}`; + const customMetadata = JSON.parse(headers.get('x-metadata') || '{}'); + const httpMetadata = { contentType: headers.get('content-type') }; + const result = await bucket.put( + keyPrefix + objectKey, + data, + { customMetadata, httpMetadata } + ); + + return new Response(JSON.stringify({ + timestamp, + objectKey, + etag: result.etag, + message: 'Data saved successfully', + }), { status: 200 }); + + } catch (e) { console.log(e); return new Response(null, { status: 400 }); } +} + +export default putDispatcher; \ No newline at end of file diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..9dee904 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,7 @@ +export async function getHashString(string, method = 'SHA-256') { + const encoder = new TextEncoder(); + const data = encoder.encode(string); + const hash = await crypto.subtle.digest(method, data); + const hashArray = Array.from(new Uint8Array(hash)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} \ No newline at end of file