Files
js-quizz/src/server.tsx
T
2023-12-12 11:43:13 -05:00

263 lines
8.5 KiB
TypeScript

import { Application, Router } from "https://deno.land/x/oak/mod.ts";
import staticFiles from "https://deno.land/x/static_files@1.1.6/mod.ts";
import * as rawQuestions from "./questions.js";
import { renderToString } from "https://esm.sh/preact-render-to-string@5.1.19?deps=preact@10.5.15";
import * as prand from 'https://unpkg.com/pure-rand/lib/esm/pure-rand.js';
import {QuestionSource, QuestionMeta} from './shared.ts';
import highlightJs from "https://esm.sh/highlight.js@11.9.0"
import highlightJsJavascript from "https://esm.sh/highlight.js@11.9.0/es/languages/javascript.js"
highlightJs.registerLanguage('javascript', highlightJsJavascript);
type Question = [QuestionSource, QuestionMeta];
type QuestionURLS = {
nextQuestionUrl: string,
validationUrl: string,
answerQuestionUrl: string
};
const questions: Question[] = Object.values(rawQuestions).flatMap(s => s.questions.map( (q, idx) => [s as QuestionSource, q as QuestionMeta]));
const apiRouter = new Router();
const loginRouter = new Router();
const app = new Application();
function hashString(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
function randomPermutation(seed: number, n: number): number[] {
const rand = prand.mersenne(seed)
const base = [...Array(n).keys()];
for(let i = 0; i < (n -1); i++){
const j = prand.unsafeUniformIntDistribution(i, n-1, rand);
const tmp = base[i];
base[i] = base[j];
base[j] = tmp;
}
return base;
}
function isSecretSeed(seed: string): boolean{
return seed === 'cannonical';
}
function getRandomQuestion<T>(seed: string, index: number, questions: Question[]): Question {
if(isSecretSeed(seed)){
return questions[index] ;
}
const numberSeed = hashString(seed);
const permutations = randomPermutation(numberSeed, questions.length)
return [... questions[permutations[index]]];
}
function getQuestionPage(question: Question, urls: QuestionURLS, questionIndex: number){
const source = highlightJs.highlight(question[0].toString(), {language: 'javascript'}).value;
return (<div>
<div style="text-align:center">
{questionIndex + 1}/{ questions.length }
</div>
<pre> <code dangerouslySetInnerHTML={{__html: source}}></code></pre>
<div class="content mt-2 mb-2" >
{ question[1].question_fr }
</div>
<div id="message">
</div>
<form>
<div class="field">
<div class="control is-expanded">
<input class="input" type="text" placeholder="Answer" name="answer"></input>
<input type="hidden" name="rendedingTimeStamp" value={Date.now()} ></input>
</div>
</div>
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<button class="button is-info"
hx-trigger="click"
hx-target="#message"
hx-post={urls.validationUrl} >
Verify Answer
</button>
</div>
<div class="control">
<a class="button is-info"
href="#"
hx-trigger="next-question from:body delay:500ms, click"
hx-get={urls.nextQuestionUrl}
hx-params="none"
hx-target="#question-body" >
Next Question
</a>
</div>
<div class="control">
<a class="button is-info"
href="#"
hx-trigger="click"
hx-get={urls.answerQuestionUrl}
hx-params="none"
hx-target="#message" >
Show Answer
</a>
</div>
</div>
</form>
</div>);
}
function getValidAnswerMessage(){
return (<div class="notification is-primary">
Fantastic! Good answer!
</div>)
}
function getInvalidAnswerMessage(){
return (<div class="notification is-danger">
Unfortunatly, this is not the right answer.
</div>)
}
function isValidAnswer(question: Question, answer: string): boolean {
return question[1].validate(answer);
}
function processInvalidAnswer(ctx, answer: string): void {
ctx.response.body = renderToString(getInvalidAnswerMessage());
}
function processValidAnswer(ctx, answer: string): void {
ctx.response.headers.append("HX-Trigger", "next-question");
ctx.response.body = renderToString(getValidAnswerMessage());
}
function getQuestionIndex(ctx){
const index = Number.parseInt(ctx.params.idx??"0");
return index % questions.length;
}
function getNextQuestionIndex(ctx){
return (getQuestionIndex(ctx) + 1) % questions.length
}
function getQuestion(ctx): Question{
const seed = ctx.params.seed;
const index = getQuestionIndex(ctx);
return getRandomQuestion(seed, index, questions);
}
function getUrls(ctx): QuestionURLS{
const seed = ctx.params.seed;
const index = getQuestionIndex(ctx);
const nextQuestionIndex = getNextQuestionIndex(ctx);
const nextQuestionUrl = `/api/v1/random-questions/${seed}/${nextQuestionIndex}`;
const validationUrl = `/api/v1/random-questions/${seed}/${index}/validate`;
const answerQuestionUrl = `/api/v1/random-questions/${seed}/${index}/answer`
return {nextQuestionUrl, validationUrl, answerQuestionUrl};
}
async function persistAnswer(ctx, form): Promise<void>{
const answer = form.get("answer");
const rendedingTimeStamp = form.get("rendedingTimeStamp");
const answeredTimestamp = Date.now();
const kv = await Deno.openKv();
const q = getQuestion(ctx);
const username = ctx.request.headers.get('session');
const data = {
ip: ctx.request.ip,
url: ctx.request.url.toString(),
date: Date.now(),
domain: ctx.request.url.hostname,
hasRightResponse: isValidAnswer(q, answer),
answer,
rendedingTimeStamp,
answeredTimestamp
}
const baseKey = ["answer", q[1].id, username];
for(let i = 0 ; i < 1000; i++){
const key = baseKey.concat([i]);
const res = await kv.atomic()
.check({ key, versionstamp: null }) // `null` versionstamps mean 'no value'
.set(key, data)
.commit();
if (res.ok) {
return
}
}
}
apiRouter.post("/api/v1/random-questions/:seed/:idx/validate", async (ctx)=> {
const form = (await ctx.request.body().value);
const answer = form.get("answer");
const q = getQuestion(ctx);
if(isValidAnswer(q, answer)){
processValidAnswer(ctx, getUrls(ctx).nextQuestionUrl)
}else{
processInvalidAnswer(ctx, getUrls(ctx).nextQuestionUrl)
}
await persistAnswer(ctx, form);
})
apiRouter.get("/api/v1/random-questions/:seed/:idx/answer", (ctx)=> {
const q = getQuestion(ctx);
ctx.response.body = renderToString(
<div class="notification is-warning">
The answer is {q[1].answer}
</div>);
})
apiRouter.get("/api/v1/random-questions/:seed/:idx", (ctx)=> {
const q = getQuestion(ctx);
const urls = getUrls(ctx);
const questionIndex = getQuestionIndex(ctx);
ctx.response.body = renderToString(getQuestionPage(q, urls, questionIndex));
ctx.response.headers.append("HX-Trigger", JSON.stringify({'set-question-id': ctx.params.idx}));
});
loginRouter.get("/toto", (ctx)=> {
ctx.response.body = highlightJs.highlight('function(){console.log("hello world")}', {language: 'javascript'}).value;
});
loginRouter.post("/api/v1/login", async (ctx)=> {
const username = (await ctx.request.body().value).get("username");
ctx.response.headers.append("HX-Trigger", JSON.stringify({'set-seed': username}));
ctx.response.body = username;
});
loginRouter.post("/api/v1/logout", (ctx)=> {
ctx.response.headers.append("HX-Trigger", "logout");
ctx.response.body = "";
});
app.use(loginRouter.routes());
app.use(staticFiles("public", {brotli: true, fetch: true}));
app.use(async (ctx, next) => {
if(!ctx.request.headers.get('session')){
console.log("user not logged in.")
ctx.response.headers.append("HX-Redirect", "/login.html");
return
}
await next();
})
app.use(apiRouter.routes());
await app.listen({ port: 8000 });