feature: switch everything to an invite-only model

This commit is contained in:
Andy Burke 2025-10-08 17:38:23 -07:00
parent a3302d2eff
commit 49c7a135d0
10 changed files with 445 additions and 3 deletions

View file

@ -0,0 +1,151 @@
import lurid from '@andyburke/lurid';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts';
import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import parse_body from '../../../../../utils/bodyparser.ts';
import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import { INVITE_CODE, INVITE_CODES, VALIDATE_INVITE_CODE } from '../../../../../models/invites.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
const INVITE_CODES_ALLOWED_PER_MINUTE = parseInt(Deno.env.get('INVITE_CODES_ALLOWED_PER_MINUTE') ?? '3', 10);
// GET /api/users/:user_id/invites - get invites this user has created
// query parameters:
// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted)
PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
const user_has_read_own_invites_permission = meta.user.permissions.includes('invites.read.own');
const user_has_read_all_invites_permission = meta.user.permissions.includes('invites.read.all');
if (!(user_has_read_all_invites_permission || (user_has_read_own_invites_permission && meta.user.id === meta.params.user_id))) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function GET(_request: Request, meta: Record<string, any>): Promise<Response> {
const sorts = INVITE_CODES.sorts;
const sort_name: string = meta.query.sort ?? 'newest';
const key = sort_name as keyof typeof sorts;
const sort: any = sorts[key];
if (!sort) {
return Response.json({
error: {
message: 'You must specify a sort: newest, oldest, latest, stalest',
cause: 'invalid_sort'
}
}, {
status: 400
});
}
const options: FSDB_SEARCH_OPTIONS<INVITE_CODE> = {
...(meta.query ?? {}),
limit: Math.min(parseInt(meta.query?.limit ?? '10'), 1_000),
sort,
filter: (entry: WALK_ENTRY<INVITE_CODE>) => {
if (meta.query.after_id && entry.path <= INVITE_CODES.get_organized_id_path(meta.query.after_id)) {
return false;
}
if (meta.query.before_id && entry.path >= INVITE_CODES.get_organized_id_path(meta.query.before_id)) {
return false;
}
return true;
}
};
const headers = {
'Cache-Control': 'no-cache, must-revalidate'
};
const results = (await INVITE_CODES.all(options))
.map((entry: WALK_ENTRY<INVITE_CODE>) => entry.load())
.sort((lhs_item: INVITE_CODE, rhs_item: INVITE_CODE) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created));
return Response.json(results, {
status: 200,
headers
});
}
// POST /api/users/:user_id/invites - Create an invite
PRECHECKS.POST = [get_session, get_user, require_user, (_request: Request, meta: Record<string, any>): Response | undefined => {
const user_has_create_invites_permission = meta.user.permissions.includes('invites.create');
if (!user_has_create_invites_permission) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
try {
const now = new Date().toISOString();
const body = await parse_body(req);
const invite_code: INVITE_CODE = {
...body,
id: lurid(),
creator_id: meta.user.id,
timestamps: {
created: now
}
};
if (typeof invite_code.code !== 'string' || invite_code.code.length === 0) {
const full_lurid = lurid();
invite_code.code = `${full_lurid.substring(0, 14).replace(/-/g, ' ')}${full_lurid.substring(39).replace(/-/g, ' ')}`;
}
const errors = VALIDATE_INVITE_CODE(invite_code);
if (errors) {
return Response.json({
errors
}, {
status: 400
});
}
const existing_code: INVITE_CODE | undefined = (await INVITE_CODES.find({ code: invite_code.code, limit: 1 })).shift()?.load();
if (existing_code) {
return Response.json({
errors: [{
cause: 'existing_invite_code',
message: 'That secret code has already been used.'
}]
}, {
status: 400
});
}
const recent_codes: INVITE_CODE[] = (await INVITE_CODES.find({
creator_id: invite_code.code,
sort: INVITE_CODES.sorts.newest,
limit: INVITE_CODES_ALLOWED_PER_MINUTE
})).map((entry) => entry.load());
const one_minute_ago = new Date(new Date(now).getTime() - 60_000).toISOString();
const codes_from_the_last_minute = recent_codes.filter((code) => (code.timestamps.created > one_minute_ago));
if (codes_from_the_last_minute.length >= INVITE_CODES_ALLOWED_PER_MINUTE) {
return Response.json({
errors: [{
cause: 'excessive_invite_code_generation',
message: 'Invite code creation is time limited, please be patient.'
}]
}, {
status: 400
});
}
await INVITE_CODES.create(invite_code);
return Response.json(invite_code, {
status: 201
});
} catch (error) {
return Response.json({
error: {
message: (error as Error).message ?? 'Unknown Error!',
cause: (error as Error).cause ?? 'unknown'
}
}, { status: 500 });
}
}

View file

@ -0,0 +1,65 @@
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts';
import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import { INVITE_CODE, INVITE_CODES, VALIDATE_INVITE_CODE } from '../../../../../models/invites.ts';
import { SIGNUP, SIGNUPS } from '../../../../../models/signups.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/users/:user_id/signups - see signups that have resulted from this user
// query parameters:
// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted)
PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
const user_has_read_own_signups_permission = meta.user.permissions.includes('signups.read.own');
const user_has_read_all_signups_permission = meta.user.permissions.includes('signups.read.all');
if (!(user_has_read_all_signups_permission || (user_has_read_own_signups_permission && meta.user.id === meta.params.user_id))) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function GET(_request: Request, meta: Record<string, any>): Promise<Response> {
const sorts = INVITE_CODES.sorts;
const sort_name: string = meta.query.sort ?? 'newest';
const key = sort_name as keyof typeof sorts;
const sort: any = sorts[key];
if (!sort) {
return Response.json({
error: {
message: 'You must specify a sort: newest, oldest, latest, stalest',
cause: 'invalid_sort'
}
}, {
status: 400
});
}
const options: FSDB_SEARCH_OPTIONS<SIGNUP> = {
...(meta.query ?? {}),
limit: Math.min(parseInt(meta.query?.limit ?? '10'), 1_000),
sort,
filter: (entry: WALK_ENTRY<INVITE_CODE>) => {
if (meta.query.after_id && entry.path <= INVITE_CODES.get_organized_id_path(meta.query.after_id)) {
return false;
}
if (meta.query.before_id && entry.path >= INVITE_CODES.get_organized_id_path(meta.query.before_id)) {
return false;
}
return true;
}
};
const headers = {
'Cache-Control': 'no-cache, must-revalidate'
};
const results = (await SIGNUPS.all(options))
.map((entry: WALK_ENTRY<SIGNUP>) => entry.load())
.sort((lhs_item: SIGNUP, rhs_item: SIGNUP) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created));
return Response.json(results, {
status: 200,
headers
});
}

View file

@ -17,6 +17,7 @@ const DEFAULT_USER_PERMISSIONS: string[] = [
'invites.read.own',
'self.read',
'self.write',
'signups.read.own',
'topics.read',
'topics.blurbs.create',
'topics.blurbs.read',

View file

@ -32,6 +32,76 @@
document.addEventListener("topics_updated", update_topic_indicators);
document.addEventListener("topic_changed", update_topic_indicators);
document.addEventListener("user_logged_in", update_topic_indicators);
function generate_invite(click_event) {
const button = click_event.target;
document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove());
const invite_div = document.createElement("div");
invite_div.classList.add("invitepopover");
invite_div.innerHTML = `<form>
<input name="code" type="text" placeholder="Custom code (optional)">
<button onclick="create_invite(event);">Generate</button>
</form>`;
document.body.appendChild(invite_div);
invite_div.style.left = button.getBoundingClientRect().left + "px";
invite_div.style.top = button.getBoundingClientRect().top + "px";
}
async function create_invite(click_event) {
click_event.preventDefault();
const button = click_event.target;
const invite_popover = document.body.querySelector(".invitepopover");
if (!invite_popover) {
alert("Unknown error, try again.");
return;
}
const user = document.body.dataset.user && JSON.parse(document.body.dataset.user);
if (!user) {
alert("You must be logged in.");
return;
}
const form = button.closest("form");
const code_input = form.querySelector('[name="code"]');
const invite_code_response = await api.fetch(`/api/users/${user.id}/invites`, {
method: "POST",
json: {
code: code_input.value,
},
});
if (!invite_code_response.ok) {
const error = await invite_code_response.json();
return alert(error?.error?.message ?? error?.errors?.[0]?.message ?? "Unknown error.");
}
const invite_code = await invite_code_response.json();
console.dir({
invite_code,
});
invite_popover.innerHTML = `
<div>
<div class="share-option">
<span class="name">Code</span>
<input readonly type="text" name="code" value="${invite_code.code}" />
<button onclick="navigator.clipboard.writeText('${invite_code.code}');" />Copy</button>
</div>
<div class="share-option">
<span class="name">Link</span>
<input readonly type="text" name="code" value="${window.location.protocol + "//" + window.location.host + "/?invite_code=" + encodeURIComponent(invite_code.code)}" />
<button onclick="navigator.clipboard.writeText('${window.location.protocol + "//" + window.location.host + "/?invite_code=" + encodeURIComponent(invite_code.code)}');" />Copy</button>
</div>
<button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button>
</div>`;
}
</script>
<style type="text/css">
@ -147,6 +217,42 @@
#sidebar .topic-list > li.topic.active a {
color: var(--accent);
}
.invitepopover {
position: fixed;
z-index: 1000;
background: var(--bg);
margin: 1rem;
border: 1px solid var(--border-normal);
border-radius: var(--border-radius);
padding: 1rem;
}
.invitepopover .share-option .name {
text-transform: uppercase;
min-width: 4rem;
display: inline-block;
}
.share-option input {
padding: 0.75rem;
margin: 0 1rem 1rem 0;
background: none;
color: var(--text);
border: 1px solid var(--border-highlight);
border-radius: var(--border-radius);
box-shadow: none;
font-size: large;
width: 15rem;
}
.share-option button {
padding: 1rem;
text-transform: uppercase;
border: 1px solid var(--border-subtle);
border-radius: var(--border-radius);
margin-left: -0.75rem;
}
</style>
<div id="sidebar">
@ -381,6 +487,13 @@
</details>
</form>
<button
style="text-transform: uppercase; width: 100%; padding: 1.1rem 0"
onclick="generate_invite(event)"
>
Invite Another Human
</button>
<form
data-smart="true"
data-method="DELETE"

View file

@ -28,6 +28,7 @@
body[data-user] #signup-login-wall {
visibility: hidden;
opacity: 0;
display: none;
}
#signup-login-wall .limiter {
@ -125,6 +126,19 @@
<label class="placeholder" for="signup-password">password</label>
</div>
<div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const query = new URL(document.location.toString())
.searchParams;
const invite_code = query.get("invite_code");
if (typeof invite_code === "string" && invite_code.length) {
document.getElementById("signup-invite-code").value =
decodeURIComponent(invite_code);
document.getElementById("signup-tab-input").checked = true;
}
});
</script>
<input
id="signup-invite-code"
type="text"

7
public/signup_pitch.md Normal file
View file

@ -0,0 +1,7 @@
# verifiedhuman.network
## You're here because someone else said you were a cool human.
### Use your invite code to gain access.
#### Remember, never give an invite code to someone you don't personally know.

View file

@ -126,6 +126,10 @@
.blurb-container .replies-container {
grid-area: replies;
}
.blurb-container .html-from-markdown {
padding: 0;
}
</style>
<div id="blurbs" class="tab">
@ -175,7 +179,7 @@
<span class="short">${blurb_datetime.short}</span>
</div>
</div>
<div class="content-container">${blurb.data.blurb}</div>
<div class="content-container">${md_to_html(blurb.data.blurb)}</div>
<!-- #include file="./new_blurb.html" -->
<div class="replies-container"></div>
</div>`;

View file

@ -131,9 +131,7 @@
<div class="tabs">
<!-- #include file="./chat/chat.html" -->
<!-- #include file="./live/live.html" -->
<!-- #include file="./blurbs/blurbs.html" -->
<!-- #include file="./forum/forum.html" -->
<!-- #include file="./essays/essays.html" -->
<!-- #include file="./calendar/calendar.html" -->
</div>