forked from andyburke/autonomous.contact
feature: switch everything to an invite-only model
This commit is contained in:
parent
a3302d2eff
commit
49c7a135d0
10 changed files with 445 additions and 3 deletions
53
models/invites.ts
Normal file
53
models/invites.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { FSDB_COLLECTION } from '@andyburke/fsdb';
|
||||
import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers';
|
||||
import { by_character, by_lurid } from '@andyburke/fsdb/organizers';
|
||||
|
||||
export type INVITE_CODE = {
|
||||
id: string;
|
||||
creator_id: string;
|
||||
code: string;
|
||||
timestamps: {
|
||||
created: string;
|
||||
expires?: string;
|
||||
cancelled?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const INVITE_CODES = new FSDB_COLLECTION<INVITE_CODE>({
|
||||
name: 'invite_codes',
|
||||
indexers: {
|
||||
code: new FSDB_INDEXER_SYMLINKS<INVITE_CODE>({
|
||||
name: 'code',
|
||||
field: 'code',
|
||||
organize: by_character
|
||||
}),
|
||||
creator_id: new FSDB_INDEXER_SYMLINKS<INVITE_CODE>({
|
||||
name: 'creator_id',
|
||||
field: 'creator_id',
|
||||
to_many: true,
|
||||
organize: by_lurid
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: separate out these different validators somewhere?
|
||||
export function VALIDATE_INVITE_CODE(invite_code: INVITE_CODE) {
|
||||
const errors: any[] = [];
|
||||
|
||||
if (typeof invite_code.id !== 'string' || invite_code.id.length !== 49) {
|
||||
errors.push({
|
||||
cause: 'invalid_invite_code_id',
|
||||
message: 'An invite code must have a lurid id, eg: able-fish-gold-wing-trip-form-seed-cost-rope-wife'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: further invite code validation
|
||||
if (typeof invite_code.code !== 'string' || invite_code.id.length < 3) {
|
||||
errors.push({
|
||||
cause: 'invalid_invite_code_code',
|
||||
message: 'An invite code must have a secret code that is at least 3 characters long.'
|
||||
});
|
||||
}
|
||||
|
||||
return errors.length ? errors : undefined;
|
||||
}
|
||||
36
models/signups.ts
Normal file
36
models/signups.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { FSDB_COLLECTION } from '@andyburke/fsdb';
|
||||
import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers';
|
||||
import { by_lurid } from '@andyburke/fsdb/organizers';
|
||||
|
||||
export type SIGNUP = {
|
||||
id: string;
|
||||
invite_code_id: string;
|
||||
referring_user_id: string;
|
||||
user_id: string;
|
||||
timestamps: {
|
||||
created: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const SIGNUPS = new FSDB_COLLECTION<SIGNUP>({
|
||||
name: 'signups',
|
||||
indexers: {
|
||||
user_id: new FSDB_INDEXER_SYMLINKS<SIGNUP>({
|
||||
name: 'user_id',
|
||||
field: 'user_id',
|
||||
organize: by_lurid
|
||||
}),
|
||||
invite_code_id: new FSDB_INDEXER_SYMLINKS<SIGNUP>({
|
||||
name: 'invite_code_id',
|
||||
field: 'invite_code_id',
|
||||
to_many: true,
|
||||
organize: by_lurid
|
||||
}),
|
||||
referring_user_id: new FSDB_INDEXER_SYMLINKS<SIGNUP>({
|
||||
name: 'referring_user_id',
|
||||
field: 'referring_user_id',
|
||||
to_many: true,
|
||||
organize: by_lurid
|
||||
})
|
||||
}
|
||||
});
|
||||
151
public/api/users/:user_id/invites/index.ts
Normal file
151
public/api/users/:user_id/invites/index.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
65
public/api/users/:user_id/signups/index.ts
Normal file
65
public/api/users/:user_id/signups/index.ts
Normal 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
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
7
public/signup_pitch.md
Normal 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.
|
||||
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue