Compare commits
26 Commits
db872df672
...
44a80255bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44a80255bb | ||
|
|
d4da09dc5a | ||
|
|
307e11d4c7 | ||
|
|
2dda72cfa3 | ||
|
|
b3c18b56ed | ||
|
|
c16b6b04ff | ||
|
|
44c670b7db | ||
|
|
f750f055b7 | ||
|
|
f5bd9f27ce | ||
|
|
2520e8156b | ||
|
|
a405193f79 | ||
|
|
fa22d5cb0e | ||
|
|
74830411d0 | ||
|
|
ce1c1e0205 | ||
|
|
706522eeef | ||
|
|
b737b440b1 | ||
|
|
7005dc8292 | ||
|
|
18d64d328c | ||
|
|
a1d9e30030 | ||
|
|
4241b83721 | ||
|
|
9686d1c7dd | ||
|
|
324d4f430e | ||
|
|
ec8d451df8 | ||
|
|
9be5d8235e | ||
|
|
f958dcacc4 | ||
|
|
c8bd8a591d |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cmd/server/server
|
||||||
124
cmd/server/internal/public/index.html
Normal file
124
cmd/server/internal/public/index.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<header>
|
||||||
|
<!--<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/light.css">-->
|
||||||
|
<link rel="stylesheet" href="/light.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
:not(#answers-list[hidden]) + #question-text,
|
||||||
|
:not(#answers-list[hidden]) + #question-text + #answer-form,
|
||||||
|
:not(#question-options:empty) + #answers-freeform,
|
||||||
|
#question-text:empty + #answer-form
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
<div id="answers-list"></div>
|
||||||
|
<div id="question-text"></div>
|
||||||
|
<form id="answer-form" action="#" onsubmit="submitAnswersForm(this); return false;">
|
||||||
|
<fieldset id="question-options" name="question-options"></fieldset>
|
||||||
|
<input id="answers-freeform" name="answers-freeform" type="text" hidden/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
<script>
|
||||||
|
let g_questions = [];
|
||||||
|
let g_live_question = {};
|
||||||
|
|
||||||
|
function pollState() {
|
||||||
|
http("GET", "/api/v1/questions", (body) => {
|
||||||
|
if (!body)
|
||||||
|
return;
|
||||||
|
g_questions = JSON.parse(body);
|
||||||
|
pollLiveAnswer();
|
||||||
|
pollLiveQuestion();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollLiveQuestion() {
|
||||||
|
let live_questions = g_questions.filter((q) => q.Live);
|
||||||
|
if (live_questions) {
|
||||||
|
g_live_question = live_questions[0];
|
||||||
|
}
|
||||||
|
if (g_live_question) {
|
||||||
|
let options = "";
|
||||||
|
for (let i of g_live_question.Options)
|
||||||
|
options += `<div>
|
||||||
|
<input type="radio" value="${i}" name="g_live_question.Options" />
|
||||||
|
<label for="${i}">${i}</label>
|
||||||
|
</div>`
|
||||||
|
if (document.getElementById("question-text").value != g_live_question.Text) {
|
||||||
|
document.getElementById("question-text").innerHTML = `<h1>${g_live_question.Text}</h1>`;
|
||||||
|
document.getElementById("question-text").value = g_live_question.Text;
|
||||||
|
document.getElementById("question-options").innerHTML = options;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById("question-text").innerHTML = "";
|
||||||
|
document.getElementById("question-options").innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollLiveAnswer() {
|
||||||
|
if (!g_live_question || !g_live_question.Closed) {
|
||||||
|
document.getElementById("answers-list").hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("answers-list").hidden = false;
|
||||||
|
http("GET", `/api/v1/questions/${g_live_question.ID}/answers`, (body) => {
|
||||||
|
let answers = JSON.parse(body).map((a) => a.Text);
|
||||||
|
answers.sort((a, b) => {
|
||||||
|
a = a.toLowerCase();
|
||||||
|
b = b.toLowerCase();
|
||||||
|
return (a > b) - (a < b)
|
||||||
|
});
|
||||||
|
let result = "";
|
||||||
|
for (let i of answers)
|
||||||
|
result += `<li>${i}</li>`;
|
||||||
|
document.getElementById("answers-list").innerHTML = `<ul>${result}</ul>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAnswersForm(form) {
|
||||||
|
let children = [];
|
||||||
|
for (let e of form["question-options"].elements) {
|
||||||
|
children.push(e);
|
||||||
|
}
|
||||||
|
let checked_children = children.filter((c) => c.checked);
|
||||||
|
let text = form["answers-freeform"].value;
|
||||||
|
if (checked_children)
|
||||||
|
text = checked_children[0].value;
|
||||||
|
http("POST", `/api/v1/questions/${g_live_question.ID}/answers`, (body, status) => {
|
||||||
|
console.log(status, body);
|
||||||
|
}, JSON.stringify({
|
||||||
|
Text: text,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function http(method, remote, callback, body) {
|
||||||
|
var xmlhttp = new XMLHttpRequest();
|
||||||
|
xmlhttp.onreadystatechange = function() {
|
||||||
|
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
|
||||||
|
callback(xmlhttp.responseText, xmlhttp.status)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xmlhttp.open(method, remote, true);
|
||||||
|
if(typeof body == "undefined") {
|
||||||
|
body = null
|
||||||
|
}
|
||||||
|
xmlhttp.send(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
pollState();
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
pollState();
|
||||||
|
} catch {}
|
||||||
|
}, 1000);
|
||||||
|
</script>
|
||||||
|
</footer>
|
||||||
|
</html>
|
||||||
886
cmd/server/internal/public/light.css
Normal file
886
cmd/server/internal/public/light.css
Normal file
@@ -0,0 +1,886 @@
|
|||||||
|
/**
|
||||||
|
* Forced light theme version
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background-body: #fff;
|
||||||
|
--background: #efefef;
|
||||||
|
--background-alt: #f7f7f7;
|
||||||
|
--selection: #9e9e9e;
|
||||||
|
--text-main: #363636;
|
||||||
|
--text-bright: #000;
|
||||||
|
--text-muted: #70777f;
|
||||||
|
--links: #0076d1;
|
||||||
|
--focus: #0096bfab;
|
||||||
|
--border: #dbdbdb;
|
||||||
|
--code: #000;
|
||||||
|
--animation-duration: 0.1s;
|
||||||
|
--button-base: #d0cfcf;
|
||||||
|
--button-hover: #9b9b9b;
|
||||||
|
--scrollbar-thumb: rgb(170, 170, 170);
|
||||||
|
--scrollbar-thumb-hover: var(--button-hover);
|
||||||
|
--form-placeholder: #949494;
|
||||||
|
--form-text: #1d1d1d;
|
||||||
|
--variable: #39a33c;
|
||||||
|
--highlight: #ff0;
|
||||||
|
--select-arrow: url("data:image/svg+xml;charset=utf-8,%3C?xml version='1.0' encoding='utf-8'?%3E %3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' height='62.5' width='116.9' fill='%23161f27'%3E %3Cpath d='M115.3,1.6 C113.7,0 111.1,0 109.5,1.6 L58.5,52.7 L7.4,1.6 C5.8,0 3.2,0 1.6,1.6 C0,3.2 0,5.8 1.6,7.4 L55.5,61.3 C56.3,62.1 57.3,62.5 58.4,62.5 C59.4,62.5 60.5,62.1 61.3,61.3 L115.2,7.4 C116.9,5.8 116.9,3.2 115.3,1.6Z'/%3E %3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-color: rgb(170, 170, 170) #fff;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) var(--background-body);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Color Emoji', sans-serif;
|
||||||
|
line-height: 1.4;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 10px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
color: #363636;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: #fff;
|
||||||
|
background: var(--background-body);
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
transition:
|
||||||
|
background-color 0.1s linear,
|
||||||
|
border-color 0.1s linear,
|
||||||
|
color 0.1s linear,
|
||||||
|
box-shadow 0.1s linear,
|
||||||
|
transform 0.1s ease;
|
||||||
|
transition:
|
||||||
|
background-color var(--animation-duration) linear,
|
||||||
|
border-color var(--animation-duration) linear,
|
||||||
|
color var(--animation-duration) linear,
|
||||||
|
box-shadow var(--animation-duration) linear,
|
||||||
|
transform var(--animation-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
transition:
|
||||||
|
background-color 0.1s linear,
|
||||||
|
border-color 0.1s linear,
|
||||||
|
color 0.1s linear,
|
||||||
|
box-shadow 0.1s linear,
|
||||||
|
transform 0.1s ease;
|
||||||
|
transition:
|
||||||
|
background-color var(--animation-duration) linear,
|
||||||
|
border-color var(--animation-duration) linear,
|
||||||
|
color var(--animation-duration) linear,
|
||||||
|
box-shadow var(--animation-duration) linear,
|
||||||
|
transform var(--animation-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
transition:
|
||||||
|
background-color 0.1s linear,
|
||||||
|
border-color 0.1s linear,
|
||||||
|
color 0.1s linear,
|
||||||
|
box-shadow 0.1s linear,
|
||||||
|
transform 0.1s ease;
|
||||||
|
transition:
|
||||||
|
background-color var(--animation-duration) linear,
|
||||||
|
border-color var(--animation-duration) linear,
|
||||||
|
color var(--animation-duration) linear,
|
||||||
|
box-shadow var(--animation-duration) linear,
|
||||||
|
transform var(--animation-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.2em;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
b,
|
||||||
|
strong,
|
||||||
|
th {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
q::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
q::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #0096bfab;
|
||||||
|
border-left: 4px solid var(--focus);
|
||||||
|
margin: 1.5em 0;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
q {
|
||||||
|
border-left: 4px solid #0096bfab;
|
||||||
|
border-left: 4px solid var(--focus);
|
||||||
|
margin: 1.5em 0;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote > footer {
|
||||||
|
font-style: normal;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote cite {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a[href^='mailto\:']::before {
|
||||||
|
content: '📧 ';
|
||||||
|
}
|
||||||
|
|
||||||
|
a[href^='tel\:']::before {
|
||||||
|
content: '📞 ';
|
||||||
|
}
|
||||||
|
|
||||||
|
a[href^='sms\:']::before {
|
||||||
|
content: '💬 ';
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background-color: #ff0;
|
||||||
|
background-color: var(--highlight);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 2px 0 2px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
a > code,
|
||||||
|
a > strong {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
input[type='submit'],
|
||||||
|
input[type='reset'],
|
||||||
|
input[type='button'],
|
||||||
|
input[type='checkbox'],
|
||||||
|
input[type='range'],
|
||||||
|
input[type='radio'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type='checkbox'],
|
||||||
|
[type='radio'] {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
color: #1d1d1d;
|
||||||
|
color: var(--form-text);
|
||||||
|
background-color: #efefef;
|
||||||
|
background-color: var(--background);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: #1d1d1d;
|
||||||
|
color: var(--form-text);
|
||||||
|
background-color: #efefef;
|
||||||
|
background-color: var(--background);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
color: #1d1d1d;
|
||||||
|
color: var(--form-text);
|
||||||
|
background-color: #efefef;
|
||||||
|
background-color: var(--background);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
color: #1d1d1d;
|
||||||
|
color: var(--form-text);
|
||||||
|
background-color: #efefef;
|
||||||
|
background-color: var(--background);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #d0cfcf;
|
||||||
|
background-color: var(--button-base);
|
||||||
|
padding-right: 30px;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='submit'] {
|
||||||
|
background-color: #d0cfcf;
|
||||||
|
background-color: var(--button-base);
|
||||||
|
padding-right: 30px;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='reset'] {
|
||||||
|
background-color: #d0cfcf;
|
||||||
|
background-color: var(--button-base);
|
||||||
|
padding-right: 30px;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='button'] {
|
||||||
|
background-color: #d0cfcf;
|
||||||
|
background-color: var(--button-base);
|
||||||
|
padding-right: 30px;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #9b9b9b;
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='submit']:hover {
|
||||||
|
background: #9b9b9b;
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='reset']:hover {
|
||||||
|
background: #9b9b9b;
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='button']:hover {
|
||||||
|
background: #9b9b9b;
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'],
|
||||||
|
input[type='radio'] {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='radio'] {
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not([type='checkbox']):not([type='radio']),
|
||||||
|
input[type='range'],
|
||||||
|
select,
|
||||||
|
button,
|
||||||
|
textarea {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
display: block;
|
||||||
|
margin-right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:not([cols]) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:not([rows]) {
|
||||||
|
min-height: 40px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: #efefef url("data:image/svg+xml;charset=utf-8,%3C?xml version='1.0' encoding='utf-8'?%3E %3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' height='62.5' width='116.9' fill='%23161f27'%3E %3Cpath d='M115.3,1.6 C113.7,0 111.1,0 109.5,1.6 L58.5,52.7 L7.4,1.6 C5.8,0 3.2,0 1.6,1.6 C0,3.2 0,5.8 1.6,7.4 L55.5,61.3 C56.3,62.1 57.3,62.5 58.4,62.5 C59.4,62.5 60.5,62.1 61.3,61.3 L115.2,7.4 C116.9,5.8 116.9,3.2 115.3,1.6Z'/%3E %3C/svg%3E") calc(100% - 12px) 50% / 12px no-repeat;
|
||||||
|
background: var(--background) var(--select-arrow) calc(100% - 12px) 50% / 12px no-repeat;
|
||||||
|
padding-right: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select::-ms-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select[multiple] {
|
||||||
|
padding-right: 10px;
|
||||||
|
background-image: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
box-shadow: 0 0 0 2px #0096bfab;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
box-shadow: 0 0 0 2px #0096bfab;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
box-shadow: 0 0 0 2px #0096bfab;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
box-shadow: 0 0 0 2px #0096bfab;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:active,
|
||||||
|
input[type='radio']:active,
|
||||||
|
input[type='submit']:active,
|
||||||
|
input[type='reset']:active,
|
||||||
|
input[type='button']:active,
|
||||||
|
input[type='range']:active,
|
||||||
|
button:active {
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
button:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-placeholder {
|
||||||
|
color: #949494;
|
||||||
|
color: var(--form-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
:-ms-input-placeholder {
|
||||||
|
color: #949494;
|
||||||
|
color: var(--form-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-ms-input-placeholder {
|
||||||
|
color: #949494;
|
||||||
|
color: var(--form-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
::placeholder {
|
||||||
|
color: #949494;
|
||||||
|
color: var(--form-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: 1px #0096bfab solid;
|
||||||
|
border: 1px var(--focus) solid;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 9.5px;
|
||||||
|
-webkit-transition: 0.2s;
|
||||||
|
transition: 0.2s;
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
box-shadow: 0 1px 1px #000, 0 0 1px #0d0d0d;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dbdbdb;
|
||||||
|
background: var(--border);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin-top: -7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']:focus::-webkit-slider-runnable-track {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 9.5px;
|
||||||
|
-moz-transition: 0.2s;
|
||||||
|
transition: 0.2s;
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
box-shadow: 1px 1px 1px #000, 0 0 1px #0d0d0d;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dbdbdb;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-ms-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 9.5px;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
border-width: 16px 0;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-ms-fill-lower {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
border: 0.2px solid #010101;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 1px 1px 1px #000, 0 0 1px #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-ms-fill-upper {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
border: 0.2px solid #010101;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 1px 1px 1px #000, 0 0 1px #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-ms-thumb {
|
||||||
|
box-shadow: 1px 1px 1px #000, 0 0 1px #0d0d0d;
|
||||||
|
border: 1px solid #000;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dbdbdb;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']:focus::-ms-fill-lower {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']:focus::-ms-fill-upper {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #0076d1;
|
||||||
|
color: var(--links);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
color: #000;
|
||||||
|
color: var(--code);
|
||||||
|
padding: 2.5px 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
samp {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
color: #000;
|
||||||
|
color: var(--code);
|
||||||
|
padding: 2.5px 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
time {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
color: #000;
|
||||||
|
color: var(--code);
|
||||||
|
padding: 2.5px 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre > code {
|
||||||
|
padding: 10px;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
var {
|
||||||
|
color: #39a33c;
|
||||||
|
color: var(--variable);
|
||||||
|
font-style: normal;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid #dbdbdb;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #363636;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 2px 4px 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #dbdbdb;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
table caption {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
border-bottom: 1px solid #dbdbdb;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot {
|
||||||
|
border-top: 1px solid #dbdbdb;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(even) {
|
||||||
|
background-color: #efefef;
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(even) button {
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
background-color: var(--background-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(even) button:hover {
|
||||||
|
background-color: #fff;
|
||||||
|
background-color: var(--background-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #efefef;
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(170, 170, 170);
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9b9b9b;
|
||||||
|
background: var(--scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-selection {
|
||||||
|
background-color: #9e9e9e;
|
||||||
|
background-color: var(--selection);
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: #9e9e9e;
|
||||||
|
background-color: var(--selection);
|
||||||
|
color: #000;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
background-color: var(--background-alt);
|
||||||
|
padding: 10px 10px 0;
|
||||||
|
margin: 1em 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] summary {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
background-color: #efefef;
|
||||||
|
background-color: var(--background);
|
||||||
|
padding: 10px;
|
||||||
|
margin: -10px -10px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary:hover,
|
||||||
|
summary:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
details > :not(summary) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
color: #363636;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
background-color: var(--background-alt);
|
||||||
|
color: #363636;
|
||||||
|
color: var(--text-main);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-color: #dbdbdb;
|
||||||
|
border-color: var(--border);
|
||||||
|
padding: 10px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog > header:first-child {
|
||||||
|
background-color: #efefef;
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
margin: -10px -30px 10px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::-webkit-backdrop {
|
||||||
|
background: #0000009c;
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background: #0000009c;
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid #dbdbdb;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 10px;
|
||||||
|
color: #70777f;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body,
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
footer,
|
||||||
|
summary,
|
||||||
|
strong {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::marker {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(even) {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #00f;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
309
cmd/server/main.go
Normal file
309
cmd/server/main.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
RPS int
|
||||||
|
fsDB string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
cfg Config
|
||||||
|
limiter *rate.Limiter
|
||||||
|
db DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type DB interface {
|
||||||
|
GetQuestions() ([]Question, error)
|
||||||
|
GetQuestion(string) (Question, error)
|
||||||
|
InsertAnswer(string, string, Answer) error
|
||||||
|
GetAnswers(string) ([]Answer, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fsDB string
|
||||||
|
|
||||||
|
type Question struct {
|
||||||
|
ID string
|
||||||
|
Live bool
|
||||||
|
Closed bool
|
||||||
|
Text string
|
||||||
|
Options []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Answer struct {
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
User struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
||||||
|
defer can()
|
||||||
|
if err := run(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context) error {
|
||||||
|
cfg, err := newConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runHTTP(ctx, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfig() (Config, error) {
|
||||||
|
cfg := Config{}
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||||
|
fs.StringVar(&cfg.Addr, "addr", ":8080", "address to listen on")
|
||||||
|
fs.IntVar(&cfg.RPS, "rps", 100, "requests per second to serve")
|
||||||
|
fs.StringVar(&cfg.fsDB, "fs-db", "/tmp/live-audience.d", "api dir to serve")
|
||||||
|
|
||||||
|
err := fs.Parse(os.Args[1:])
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg Config) NewHandler() Handler {
|
||||||
|
return Handler{
|
||||||
|
cfg: cfg,
|
||||||
|
limiter: rate.NewLimiter(rate.Limit(cfg.RPS), 10),
|
||||||
|
db: fsDB(cfg.fsDB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Session) Empty() bool {
|
||||||
|
return s == (Session{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHTTP(ctx context.Context, cfg Config) error {
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.Addr,
|
||||||
|
Handler: cfg.NewHandler(),
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
server.Close()
|
||||||
|
}()
|
||||||
|
log.Println("listening on", cfg.Addr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && ctx.Err() == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.serveHTTP(w, r); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
h.limiter.Wait(r.Context())
|
||||||
|
|
||||||
|
session, err := h.auth(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if session.Empty() {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Basic realm=xyz")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`IDENTIFY YOURSELF!`))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.handle(session, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) auth(r *http.Request) (Session, error) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
return Session{}, nil
|
||||||
|
}
|
||||||
|
session := Session{}
|
||||||
|
session.User.ID = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass)))
|
||||||
|
session.User.Name = user
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed internal/public
|
||||||
|
var _public embed.FS
|
||||||
|
var public = func() http.FileSystem {
|
||||||
|
d, err := fs.Sub(_public, "internal/public")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return http.FS(d)
|
||||||
|
}()
|
||||||
|
|
||||||
|
func (h Handler) handle(session Session, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=60")
|
||||||
|
http.FileServer(public).ServeHTTP(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers := map[string]func(Session, http.ResponseWriter, *http.Request) error{
|
||||||
|
`^/api/v1/questions$`: h.handleAPIV1Questions,
|
||||||
|
`^/api/v1/questions/[^/]*$`: h.handleAPIV1Question,
|
||||||
|
`^/api/v1/questions/[^/]*/answers$`: h.handleAPIV1QuestionsAnswers,
|
||||||
|
}
|
||||||
|
for k, v := range handlers {
|
||||||
|
if regexp.MustCompile(k).MatchString(r.URL.Path) {
|
||||||
|
return v(session, w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) handleAPIV1Question(session Session, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
qid := path.Base(r.URL.Path)
|
||||||
|
q, err := h.db.GetQuestion(qid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.NewEncoder(w).Encode(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) handleAPIV1Questions(session Session, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
qs, err := h.db.GetQuestions()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.NewEncoder(w).Encode(qs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) handleAPIV1QuestionsAnswers(session Session, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
return h.handleAPIV1QuestionsAnswersGet(session, w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
return h.handleAPIV1QuestionsAnswersPost(session, w, r)
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) handleAPIV1QuestionsAnswersGet(session Session, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
qid := path.Base(path.Dir(r.URL.Path))
|
||||||
|
as, err := h.db.GetAnswers(qid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.NewEncoder(w).Encode(as)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) handleAPIV1QuestionsAnswersPost(session Session, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
qid := path.Base(path.Dir(r.URL.Path))
|
||||||
|
uid := session.User.ID
|
||||||
|
var a Answer
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||||
|
return fmt.Errorf("failed to read answer: %w", err)
|
||||||
|
}
|
||||||
|
return h.db.InsertAnswer(qid, uid, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db fsDB) GetQuestion(qid string) (Question, error) {
|
||||||
|
p := db.path(qid)
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return Question{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var q Question
|
||||||
|
if err := json.Unmarshal(b, &q); err != nil {
|
||||||
|
return Question{}, fmt.Errorf("failed to parse %s as question: %w", b, err)
|
||||||
|
}
|
||||||
|
q.ID = qid
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db fsDB) InsertAnswer(qid, uid string, a Answer) error {
|
||||||
|
p := path.Join(db.path(qid)+".d", uid)
|
||||||
|
os.MkdirAll(path.Dir(p), os.ModePerm)
|
||||||
|
b, err := json.Marshal(a)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.WriteFile(p, b, os.ModePerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db fsDB) GetQuestions() ([]Question, error) {
|
||||||
|
p := db.path("")
|
||||||
|
entries, err := os.ReadDir(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := []Question{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(path.Base(entry.Name()), ".") || entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qid := path.Base(entry.Name())
|
||||||
|
q, err := db.GetQuestion(qid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, q)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db fsDB) GetAnswers(qid string) ([]Answer, error) {
|
||||||
|
p := db.path(qid) + ".d"
|
||||||
|
entries, err := os.ReadDir(p)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := []Answer{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(path.Base(entry.Name()), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path.Join(p, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var a Answer
|
||||||
|
if err := json.Unmarshal(b, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse %s as answer: %w", path.Join(p, entry.Name()), err)
|
||||||
|
}
|
||||||
|
results = append(results, a)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db fsDB) path(q string) string {
|
||||||
|
return path.Join(string(db), q)
|
||||||
|
}
|
||||||
146
cmd/server/main_test.go
Normal file
146
cmd/server/main_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunHTTP(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
fsDB: t.TempDir(),
|
||||||
|
}
|
||||||
|
if err := func() error {
|
||||||
|
b, _ := json.Marshal(Question{
|
||||||
|
Text: "QUESTION TEXT",
|
||||||
|
Options: []string{"X", "Y"},
|
||||||
|
})
|
||||||
|
return os.WriteFile(path.Join(string(cfg.fsDB), "0"), b, os.ModePerm)
|
||||||
|
}(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := cfg.NewHandler()
|
||||||
|
|
||||||
|
t.Run("requires auth", func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
t.Logf("%s %s", r.Method, r.URL)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
t.Logf("(%d) %s", w.Code, w.Body.Bytes())
|
||||||
|
if w.Code != 401 {
|
||||||
|
t.Error(w.Code)
|
||||||
|
}
|
||||||
|
if w.Header().Get("WWW-Authenticate") == "" {
|
||||||
|
t.Errorf("expected WWW-Authenticate header but got %+v", w.Header())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("/", func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.SetBasicAuth("b", "b")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
t.Logf("%s %s", r.Method, r.URL)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
t.Logf("(%d) %s", w.Code, w.Body.Bytes())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Error(w.Code)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(w.Body.Bytes(), []byte("<html>")) {
|
||||||
|
t.Errorf("%s", w.Body.Bytes())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("/api/notfound", func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/notfound", nil)
|
||||||
|
r.SetBasicAuth("b", "b")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
t.Logf("%s %s", r.Method, r.URL)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
t.Logf("(%d) %s", w.Code, w.Body.Bytes())
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Error(w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("/api/v1/questions", func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/v1/questions", nil)
|
||||||
|
r.SetBasicAuth("b", "b")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
t.Logf("%s %s", r.Method, r.URL)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
t.Logf("(%d) %s", w.Code, w.Body.Bytes())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Error(w.Code)
|
||||||
|
}
|
||||||
|
var result []Question
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
} else if fmt.Sprint(result) != fmt.Sprint([]Question{{ID: "0", Live: false, Closed: false, Text: "QUESTION TEXT", Options: []string{"X", "Y"}}}) {
|
||||||
|
t.Error(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("/api/v1/questions/0", func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/v1/questions/0", nil)
|
||||||
|
r.SetBasicAuth("b", "b")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
t.Logf("%s %s", r.Method, r.URL)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
t.Logf("(%d) %s", w.Code, w.Body.Bytes())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Error(w.Code)
|
||||||
|
}
|
||||||
|
var result Question
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
} else if fmt.Sprint(result) != fmt.Sprint(Question{ID: "0", Text: "QUESTION TEXT", Options: []string{"X", "Y"}}) {
|
||||||
|
t.Error(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST /api/v1/questions/0/answers", func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/v1/questions/0/answers", strings.NewReader(`{"Text": "teehee"}`))
|
||||||
|
r.SetBasicAuth("b", "b")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
t.Logf("%s %s", r.Method, r.URL)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
t.Logf("(%d) %s", w.Code, w.Body.Bytes())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Error(w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/v1/questions/0/answers", func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/v1/questions/0/answers", nil)
|
||||||
|
r.SetBasicAuth("b", "b")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
t.Logf("%s %s", r.Method, r.URL)
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
t.Logf("(%d) %s", w.Code, w.Body.Bytes())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Error(w.Code)
|
||||||
|
}
|
||||||
|
var result []Answer
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
} else if fmt.Sprint(result) != fmt.Sprint([]Answer{{Text: "teehee"}}) {
|
||||||
|
t.Error(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublic(t *testing.T) {
|
||||||
|
f, err := public.Open("index.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
6
go.mod
6
go.mod
@@ -1,3 +1,9 @@
|
|||||||
module live-studio-audience
|
module live-studio-audience
|
||||||
|
|
||||||
go 1.21.4
|
go 1.21.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.inhome.blapointe.com/local/gziphttp v0.0.0-20240109214739-0d639b0a9eb9 // indirect
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0 // indirect
|
||||||
|
golang.org/x/time v0.5.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
gitea.inhome.blapointe.com/local/gziphttp v0.0.0-20240109214739-0d639b0a9eb9 h1:Vq4jPYz6pCDMx7rxySw7SbKH3FHBywt1oS1TpXx4noM=
|
||||||
|
gitea.inhome.blapointe.com/local/gziphttp v0.0.0-20240109214739-0d639b0a9eb9/go.mod h1:YSOO/quInfKxfgqHH8exd71292hkLgqu2BO5oz6EATE=
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
Reference in New Issue
Block a user