diff --git a/models/invites.ts b/models/invites.ts new file mode 100644 index 0000000..d81088b --- /dev/null +++ b/models/invites.ts @@ -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({ + name: 'invite_codes', + indexers: { + code: new FSDB_INDEXER_SYMLINKS({ + name: 'code', + field: 'code', + organize: by_character + }), + creator_id: new FSDB_INDEXER_SYMLINKS({ + 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; +} diff --git a/models/signups.ts b/models/signups.ts new file mode 100644 index 0000000..83f0680 --- /dev/null +++ b/models/signups.ts @@ -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({ + name: 'signups', + indexers: { + user_id: new FSDB_INDEXER_SYMLINKS({ + name: 'user_id', + field: 'user_id', + organize: by_lurid + }), + invite_code_id: new FSDB_INDEXER_SYMLINKS({ + name: 'invite_code_id', + field: 'invite_code_id', + to_many: true, + organize: by_lurid + }), + referring_user_id: new FSDB_INDEXER_SYMLINKS({ + name: 'referring_user_id', + field: 'referring_user_id', + to_many: true, + organize: by_lurid + }) + } +}); diff --git a/public/api/users/:user_id/invites/index.ts b/public/api/users/:user_id/invites/index.ts new file mode 100644 index 0000000..991167c --- /dev/null +++ b/public/api/users/:user_id/invites/index.ts @@ -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): 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): Promise { + 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 = { + ...(meta.query ?? {}), + limit: Math.min(parseInt(meta.query?.limit ?? '10'), 1_000), + sort, + filter: (entry: WALK_ENTRY) => { + 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) => 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): 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): Promise { + 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 }); + } +} diff --git a/public/api/users/:user_id/signups/index.ts b/public/api/users/:user_id/signups/index.ts new file mode 100644 index 0000000..6796244 --- /dev/null +++ b/public/api/users/:user_id/signups/index.ts @@ -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): 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): Promise { + 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 = { + ...(meta.query ?? {}), + limit: Math.min(parseInt(meta.query?.limit ?? '10'), 1_000), + sort, + filter: (entry: WALK_ENTRY) => { + 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) => 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 + }); +} diff --git a/public/api/users/index.ts b/public/api/users/index.ts index 28d1c1d..e915e6d 100644 --- a/public/api/users/index.ts +++ b/public/api/users/index.ts @@ -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', diff --git a/public/sidebar/sidebar.html b/public/sidebar/sidebar.html index 00bcfbb..18cdbba 100644 --- a/public/sidebar/sidebar.html +++ b/public/sidebar/sidebar.html @@ -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 = `
+ + +
`; + + 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 = ` +
+ + + +
`; + }
+
@@ -175,7 +179,7 @@ ${blurb_datetime.short}
-
${blurb.data.blurb}
+
${md_to_html(blurb.data.blurb)}
`; diff --git a/public/tabs/tabs.html b/public/tabs/tabs.html index f39c881..d1b9fe7 100644 --- a/public/tabs/tabs.html +++ b/public/tabs/tabs.html @@ -131,9 +131,7 @@
- -