Compare commits

..

26 Commits

Author SHA1 Message Date
Bel LaPointe
44a80255bb i guess that counts as mvp right 2024-02-20 15:39:58 -07:00
Bel LaPointe
d4da09dc5a hide answer form if no question live 2024-02-20 15:09:56 -07:00
Bel LaPointe
307e11d4c7 submit button 2024-02-20 15:05:44 -07:00
Bel LaPointe
2dda72cfa3 radios at least 2024-02-20 15:03:15 -07:00
Bel LaPointe
b3c18b56ed ok transitions stuff okay 2024-02-20 14:57:20 -07:00
Bel LaPointe
c16b6b04ff case insensitive sort answers 2024-02-20 14:51:18 -07:00
Bel LaPointe
44c670b7db answer prompt flips between question options as ul and input text 2024-02-20 14:45:52 -07:00
Bel LaPointe
f750f055b7 css is fuuuuun and bad 2024-02-20 14:35:19 -07:00
Bel LaPointe
f5bd9f27ce workin on drawing 2024-02-20 14:26:52 -07:00
Bel LaPointe
2520e8156b modules 2024-02-20 14:26:41 -07:00
Bel LaPointe
a405193f79 gitignore 2024-02-20 14:26:37 -07:00
Bel LaPointe
fa22d5cb0e download light.css 2024-02-20 14:26:29 -07:00
Bel LaPointe
74830411d0 gr 2024-02-20 11:35:11 -07:00
Bel LaPointe
ce1c1e0205 tests pass woo 2024-02-20 08:55:31 -07:00
Bel LaPointe
706522eeef ok resty enough 2024-02-20 08:45:29 -07:00
Bel LaPointe
b737b440b1 oooooo i shoulda done different but i need a router for that hmmm 2024-02-20 08:36:44 -07:00
Bel LaPointe
7005dc8292 oooooo i shoulda done different but i need a router for that hmmm 2024-02-20 08:35:55 -07:00
Bel LaPointe
18d64d328c wippper snapper 2024-02-20 08:32:50 -07:00
Bel LaPointe
a1d9e30030 tddddddd 2024-02-20 08:23:37 -07:00
Bel LaPointe
4241b83721 tdd ish 2024-02-20 08:13:51 -07:00
Bel LaPointe
9686d1c7dd test public directly 2024-02-20 08:10:03 -07:00
Bel LaPointe
324d4f430e test get to / 2024-02-20 08:09:13 -07:00
Bel LaPointe
ec8d451df8 ok we got basicauth and / serves index 2024-02-20 08:07:59 -07:00
Bel LaPointe
9be5d8235e oknow ctx bad 2024-02-20 08:00:45 -07:00
Bel LaPointe
f958dcacc4 ctx bad 2024-02-20 08:00:09 -07:00
Bel LaPointe
c8bd8a591d wipp 2024-02-20 07:58:59 -07:00
7 changed files with 1478 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cmd/server/server

View 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>

View 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
View 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
View 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
View File

@@ -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
View 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=