Implement full functionality

This commit is contained in:
HoshinoKoji 2024-09-30 13:56:05 +08:00
parent e86afb86ae
commit 63656db542
7 changed files with 228 additions and 9 deletions

30
src/auth.js Normal file
View File

@ -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;

27
src/download.js Normal file
View File

@ -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;

29
src/getAccessToken.js Normal file
View File

@ -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;

View File

@ -7,17 +7,42 @@
* *
* Learn more at https://developers.cloudflare.com/workers/ * Learn more at https://developers.cloudflare.com/workers/
*/ */
import getAccessTokenDispatcher from './getAccessToken.js';
async function getHashString(string, method = 'SHA-256') { import authDispatcher from './auth.js';
const encoder = new TextEncoder(); import listDispatcher from './list.js';
const data = encoder.encode(string); import downloadDispatcher from './download.js';
const hash = await crypto.subtle.digest(method, data); import putDispatcher from './put.js';
const hashArray = Array.from(new Uint8Array(hash));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
export default { export default {
async fetch(request, env, ctx) { 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 });
}, },
}; };

48
src/list.js Normal file
View File

@ -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;

53
src/put.js Normal file
View File

@ -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;

7
src/utils.js Normal file
View File

@ -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('');
}