move post fetching to frontend and switch to an api system instead of full ssr

This commit is contained in:
slonkazoid 2024-12-29 22:35:13 +03:00
parent 5ef9d0aca3
commit 702f8ef77d
Signed by: slonk
SSH key fingerprint: SHA256:tbZfJX4IOvZ0LGWOWu5Ijo8jfMPi78TU7x1VoEeCIjM
10 changed files with 435 additions and 154 deletions

View file

@ -1,4 +1,6 @@
.error {
#error {
display: none;
border: 2px solid red;
border-radius: 4px;
@ -7,7 +9,9 @@
color: white;
}
.info {
#success {
display: none;
border: 2px solid darkgreen;
border-radius: 4px;
@ -15,3 +19,7 @@
background-color: limegreen;
color: black;
}
#submit {
display: none;
}

221
blogin.sh
View file

@ -26,36 +26,102 @@ TAGS=(meta interactive tadiweb)
action=$(jq -r '.action // ""' <<< "$BLAG_QUERY" || :)
if [[ "${action:+x}" == "x" ]]; then
DONT_CACHE=1
fi
if [[ "$action" == "verify_token" ]]; then
RAW=application/json
metadata
#shellcheck disable=SC2266
{
jq -r '.token // ""' <<< "$BLAG_QUERY" |
bwt_verify |
[[ "$(jq -r ".till > now")" == "true" ]]
} || echo false
exit
fi
if [[ "$action" == "posts" ]]; then
RAW=application/json
metadata
db_rows "SELECT * FROM posts" |
jq -r 'map([.contents, .username, .ts, null, 0]) | select(. != [])'
exit
fi
DONT_CACHE=1 # workaround
metadata
case "$action" in
verify_token)
#shellcheck disable=SC2266
{
verified=$(jq -r '.token // ""' <<< "$BLAG_QUERY" | bwt_verify)
[[ "$(jq -r '.till > now' <<< "$verified")" == "true" ]]
printf '%s' "$verified"
} || echo false
exit
;;
posts)
db_rows "SELECT * FROM posts" |
jq -r 'map([.contents, .username, .ts, null, 0]) | select(. != [])'
exit
;;
login)
username=$(jq -r '.username' <<< "$BLAG_QUERY" || :)
password=$(jq -r '.password' <<< "$BLAG_QUERY" || :)
if ! printf '%s' "$username" | rg -Uq '^[a-zA-Z0-9_]{3,24}$'; then
json_err "username must be between 3 to 24 characters, and must consist of alphanumerical characters plus underscore"
exit
elif ! printf '%s' "$password" | rg -Uq '^.{8,108}$'; then
json_err "password must be between 8 and 108 characters"
exit
fi
{
query="SELECT password FROM users WHERE username=$(escape_sql_str "$username")"
row=$(db_row "$query")
if [[ "$row" == "null" ]]; then
hash=$(printf '%s' "$password" | argon2_hash)
echo "registering user $username" >&2
unset password
db_query "INSERT INTO users (username, password) VALUES ($(escape_sql_str "$username"), $(escape_sql_str "$hash"))" >&2
created=true
else
hash=$(jq -r '.password' <<< "$row")
if ! argon2_verify "$hash" <<< "$password"; then
json_err "invalid password"
exit
fi
created=false
fi
json_username=$(jq -Rr @json <<< "$username")
till=$(date -d "+ $SESSION_TIME" '+%s')
printf '{"till":%s,"user":%s}' \
"$till" \
"$json_username" |
bwt_sign |
jq -R "{ error: false, created: $created, user: $json_username, till: $till, token: . }"
} || json_err
exit
;;
submit)
{
token=$(jq -r '.token // ""' <<< "$BLAG_QUERY" || :)
if ! verified=$(check_token "$token"); then
json_err "$verified token"
exit
fi
user=$(jq -r .user <<< "$verified")
status=$(jq -r .status <<< "$BLAG_QUERY")
chars=${#status}
if (( chars == 0 || chars > 200 )); then
json_err "status should be between 1 and 200 characters"
exit
fi
db_query "INSERT INTO posts (username, contents, ts) VALUES (
$(escape_sql_str "$user"),
$(escape_sql_str "$status"),
$(escape_sql_str "$(date --rfc-3339=seconds)")::timestamp
) ON CONFLICT (username) DO UPDATE
SET contents=excluded.contents, ts=excluded.ts" >&2
echo '{"error":false}'
} || json_err
exit
;;
esac
echo "<style>"
cat blogin.css
echo "</style>"
@ -65,96 +131,27 @@ paragraph <<< "$DESCRIPTION"
link "https://git.slonk.ing/slonk/blogin" <<< "view source" | paragraph
token=$(jq -r '.token // ""' <<< "$BLAG_QUERY" || :)
cat partials/submit.html
cat partials/login.html
logged_in=0
if [[ "$action" == "login" ]]; then
username=$(jq -r '.username' <<< "$BLAG_QUERY" || :)
password=$(jq -r '.password' <<< "$BLAG_QUERY" || :)
if ! rg -Uq '^[a-zA-Z0-9_]{3,24}$' <<< "$username"; then
error_box "username must be between 3 to 24 characters, and must consist of alphanumerical characters plus underscore"
elif ! rg -Uq '^.{8,108}$' <<< "$password"; then
error_box "password must be between 8 and 108 characters"
else
{
query="SELECT password FROM users WHERE username=$(escape_sql_str "$username")"
row=$(db_row "$query")
if [[ "$row" == "null" ]]; then
hash=$(printf '%s' "$password" | argon2_hash)
echo "registering user $username" >&2
unset password
db_query "INSERT INTO users (username, password) VALUES ($(escape_sql_str "$username"), $(escape_sql_str "$hash"))" >&2
token=$(printf '{"till":%s,"user":%s}' "$(date -d "+ $SESSION_TIME" '+%s')" "$(jq -Rr @json <<< "$username")" | bwt_sign)
logged_in=1
info_box "account created!"
else
hash=$(jq -r '.password' <<< "$row")
if argon2_verify "$hash" <<< "$password" ; then
token=$(printf '{"till":%s,"user":%s}' "$(date -d "+ $SESSION_TIME" '+%s')" "$(jq -Rr @json <<< "$username")" | bwt_sign)
logged_in=1
info_box "logged in!"
else
error_box "invalid password"
fi
fi
} || {
error_box "failed to log in"
}
fi
elif [[ "${token:+x}" == "x" ]]; then
if { verified=$(bwt_verify <<< "$token"); } then
till=$(jq -r '.till' <<< "$verified")
now=$(date '+%s')
if (( till > now )); then
user=$(jq -r '.user' <<< "$verified")
logged_in=1
else
# token expired
error_box "session expired, please log in again"
fi
else
echo "warning: somebody tried something funny" >&2
error_box "bwt failed to validate"
fi
fi
if (( logged_in == 1 )); then
escape <<< "hello, $user" | header 2
if [[ "$action" == "submit" ]]; then
{
status=$(jq -r .status <<< "$BLAG_QUERY")
chars=${#status}
if (( chars == 0 || chars > 200 )); then
error_box "status should be between 1 and 200 characters"
fi
db_query "INSERT INTO posts (username, contents, ts) VALUES (
$(escape_sql_str "$user"),
$(escape_sql_str "$status"),
$(escape_sql_str "$(date --rfc-3339=seconds)")::timestamp
) ON CONFLICT (username) DO UPDATE
SET contents=excluded.contents, ts=excluded.ts" >&2
info_box "updated status :D"
} || error_box "failed to update status :("
fi
render partials/submit_form.sh "$token"
else
header 2 <<< "(b)login"
render partials/login_form.sh
fi
echo '<div id="error"></div>'
echo '<div id="success"></div>'
header 2 <<< "posts"
#shellcheck disable=SC2162
while read POST; do {
if [[ "${POST:+x}" == "x" ]]; then
declare -A post="$POST"
render partials/post.sh "${post[username]}" "${post[instance]}" "${post[timestamp]}" "${post[contents]}"
fi
} & done < <(parse_posts "${INSTANCES[@]}" || :)
echo '<div id="posts">loading...</div>'
wait
#shellcheck disable=SC2162
#while read POST; do {
# if [[ "${POST:+x}" == "x" ]]; then
# declare -A post="$POST"
# render partials/post.sh "${post[username]}" "${post[instance]}" "${post[timestamp]}" "${post[contents]}"
# fi
#} & done < <(parse_posts "${INSTANCES[@]}" || :)
#
#wait
echo '<script>'
cat load.js
echo '</script>'
echo '<noscript>blogin needs javascript to run :(</noscript>'

5
load.js Normal file
View file

@ -0,0 +1,5 @@
let post_name = location.pathname.match(/^\/posts\/([^\/]+)\/*$/)[1];
let el = document.createElement("script");
el.src = `/media/${post_name}/blogin.js`;
el.type = "module";
document.body.appendChild(el);

24
partials/login.html Normal file
View file

@ -0,0 +1,24 @@
<div id="login">
<h2>(b)login</h2>
<form id="login-form">
<input
type="text"
minlength="3"
maxlength="108"
placeholder="username"
name="username"
required
/>
<br />
<input
type="password"
minlength="8"
maxlength="108"
placeholder="password"
name="password"
required
/>
<br />
<input type="submit" value="log in" />
</form>
</div>

View file

@ -1,24 +0,0 @@
cat <<EOF
<form action="/posts/blogin">
<input type="hidden" name="action" value="login" />
<input
type="text"
minlength="3"
maxlength="108"
placeholder="username"
name="username"
required
/>
<br />
<input
type="password"
minlength="3"
maxlength="108"
placeholder="password"
name="password"
required
/>
<br />
<input type="submit" value="log in" />
</form>
EOF

9
partials/submit.html Normal file
View file

@ -0,0 +1,9 @@
<div id="submit">
<h2 id="welcome">welcome back!</h2>
<form id="submit-form">
<input type="text" maxlength="200" name="status" required />
<br />
<input type="submit" value="update blatus" />
<input type="button" value="log out" onclick="logout()" />
</form>
</div>

View file

@ -1,10 +0,0 @@
cat <<EOF
<form action="/posts/blogin" method="GET">
<input type="hidden" name="action" value="submit" />
<input type="hidden" name="token" value="$(escape <<< "$1")" />
<input type="text" maxlength="200" name="status" required />
<br />
<input type="submit" value="update blatus" />
</form>
EOF

238
static/blogin.js Normal file
View file

@ -0,0 +1,238 @@
import config from "./config.js";
console.log(config);
let token = localStorage.getItem("token");
let verified = false;
let loggedIn = false;
const loginDiv = document.getElementById("login");
const loginForm = document.getElementById("login-form");
const submitDiv = document.getElementById("submit");
const submitForm = document.getElementById("submit-form");
const welcomeHeader = document.getElementById("welcome");
const posts = document.getElementById("posts");
const errorBox = document.getElementById("error");
const successBox = document.getElementById("success");
let errorHideTimeout = null;
let successHideTimeout = null;
function displayError(message) {
let p = document.createElement("p");
p.innerText = message;
errorBox.appendChild(p);
errorBox.style.display = "block";
setTimeout(() => p.remove(), config.box_timeout);
if (errorHideTimeout !== null) clearTimeout(errorHideTimeout);
errorHideTimeout = setTimeout(() => {
errorBox.style.display = "none";
errorHideTimeout = null;
}, config.box_timeout);
}
function displaySuccess(message) {
let p = document.createElement("p");
p.innerText = message;
successBox.appendChild(p);
successBox.style.display = "block";
setTimeout(() => p.remove(), config.box_timeout);
if (successHideTimeout !== null) clearTimeout(successHideTimeout);
successHideTimeout = setTimeout(() => {
successBox.style.display = "none";
successHideTimeout = null;
}, config.box_timeout);
}
async function verifyToken(token) {
let url = new URL(config.endpoint);
url.searchParams.set("action", "verify_token");
url.searchParams.set("token", token);
try {
return await (await fetch(url)).json();
} catch (err) {
console.error(err);
displayError(err.message);
return false;
}
}
window.login = login;
let logoutTimeout = null;
function login(verified) {
loggedIn = true;
welcomeHeader.innerText = `welcome, ${verified.user}`;
if (logoutTimeout !== null) clearTimeout(logoutTimeout);
logoutTimeout = setTimeout(() => {
logout();
logoutTimeout = null;
}, verified.till * 1000 - Date.now());
loginDiv.style.display = "none";
submitDiv.style.display = "block";
}
function logout() {
loggedIn = false;
localStorage.removeItem("token");
token = null;
loginDiv.style.display = "block";
submitDiv.style.display = "none";
}
if (token && (verified = await verifyToken(token))) login(verified);
else logout();
loginForm.onsubmit = async (e) => {
e.preventDefault();
let data = new FormData(loginForm);
let url = new URL(config.endpoint);
url.searchParams.set("action", "login");
for (let [key, value] of data.entries()) {
url.searchParams.set(key, value);
}
try {
let value = await (await fetch(url)).json();
console.log(value);
if (value.error) {
displayError(`couldn't log in: ${value.reason ?? "unknown error"}`);
} else {
token = value.token;
localStorage.setItem("token", value.token);
verified = {
user: value.user,
till: value.till,
};
login(verified);
if (value.created) displaySuccess("account created!");
else displaySuccess(`logged in!`);
}
} catch (err) {
console.error(err);
displayError(err.message);
}
};
submitForm.onsubmit = async (e) => {
e.preventDefault();
if (!loggedIn) return;
let data = new FormData(submitForm);
let url = new URL(config.endpoint);
url.searchParams.set("action", "submit");
url.searchParams.set("token", token);
for (let [key, value] of data.entries()) {
url.searchParams.set(key, value);
}
try {
let value = await (await fetch(url)).json();
console.log(value);
if (value.error) {
displayError(
`couldn't update status: ${value.reason ?? "unknown error"}`
);
} else {
updateStatuses();
displaySuccess(`updated status!`);
}
} catch (err) {
console.error(err);
displayError(err.message);
}
};
function renderStatus(status) {
let quote = document.createElement("blockquote");
quote.classList.add("post");
let meta = document.createElement("div");
meta.classList.add("meta");
let username = document.createElement("span");
username.classList.add("username");
username.innerText = status.username;
let at = document.createElement("span");
at.classList.add("at");
at.innerText = "@";
let instance = document.createElement("a");
instance.classList.add("instance");
instance.innerText = status.instance;
instance.href = "https://" + status.instance;
instance.target = "_blank";
let timestamp = document.createElement("span");
timestamp.classList.add("timestamp", "date", "date-rfc3339");
timestamp.innerText = new Date(status.timestamp).toISOString();
meta.append(username, at, instance, " at ", timestamp);
quote.appendChild(meta);
let contents = document.createElement("p");
contents.innerText = status.contents;
quote.appendChild(contents);
return quote;
}
async function updateStatuses() {
let statuses = [];
let promises = [];
for (let [instance, endpoint] of Object.entries(config.instances)) {
promises.push(
(async () => {
try {
console.log(instance, endpoint);
let res = await fetch(endpoint);
console.log(res);
let value = await res.json();
for (let [contents, username, timestamp] of value)
statuses.push({
username,
timestamp: Date.parse(timestamp),
contents,
instance,
});
} catch (err) {
console.error(err);
displayError(err.message);
}
})()
);
}
await Promise.all(promises);
statuses.sort(({ timestamp: a }, { timestamp: b }) => b - a);
let start = performance.now();
statuses = statuses.map(renderStatus);
let diff = performance.now() - start;
console.log(diff);
posts.replaceChildren(...statuses);
replaceDates();
}
// initialization code
window.logout = logout;
updateStatuses();

15
static/config.js Normal file
View file

@ -0,0 +1,15 @@
// who up logi they verse
const config = {
endpoint: `${location.protocol}//${location.host}${location.pathname}`,
instances: {
"todepond.com": "https://todepond-lablogingetusers.web.val.run",
"svenlaa.com": "https://api.svenlaa.com/logiverse/logs",
"evolved.systems": "https://evol-lablogingetusers.web.val.run",
},
box_timeout: 10000,
};
config.instances[location.hostname] = `${config.endpoint}?action=posts`;
export default config;

31
util.sh
View file

@ -6,11 +6,11 @@ bwt_sign() {
"$(jq -Rsr @json <<< "$data")" \
"$(jq -Rsr @json <<< "$signature")" |
zstd -1 | base64 -w0 |
sed 's,=,_E_,g;s,/,_S_,g;s,+,_P_,g' # urlsafe base64 but evil
sed 's,=,_E,g;s,/,_S,g;s,+,_P,g' # urlsafe base64 but evil
}
bwt_verify() {
json=$(sed 's,_E_,=,g;s,_S_,/,g;s,_P_,+,g' | base64 -d | zstd -d)
json=$(sed 's,_E,=,g;s,_S,/,g;s,_P,+,g' | base64 -d | zstd -d)
data=$(jq -r '.d' <<< "$json")
signature=$(jq -r '.s' <<< "$json")
@ -44,7 +44,7 @@ argon2_hash() {
}
argon2_verify() {
$(cash -O2 -pipe -largon2 <<EOF
$(cash -O2 -pipe -largon2 <<< '
#include <argon2.h>
#include <errno.h>
#include <error.h>
@ -57,9 +57,7 @@ int main(int argc, char **argv) {
if (result == ARGON2_OK) return 0;
else return 1;
}
EOF
) "${1?need hash}" "$(</dev/stdin)"
}') "${1?need hash}" "$(</dev/stdin)"
}
error_box() {
@ -94,3 +92,24 @@ parse_posts() {
rm "$pipe"
}
json_err() {
printf '%s' "$1" | jq -R '{ error: true, reason: (if . == "" then null else . end) }'
}
check_token() {
if ! verified=$(bwt_verify <<< "$1"); then
printf 'invalid'
return 1
fi
till=$(jq -r '.till' <<< "$verified")
now=$(date '+%s')
if (( till < now )); then
printf 'expired'
return 1
fi
printf '%s' "$verified"
}