feature: initial commit

This commit is contained in:
Andy Burke 2025-06-24 15:40:30 -07:00
commit 2c27f003c9
15 changed files with 1601 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
data/
.fsdb

0
README.md Normal file
View file

41
deno.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "@andyburke/autonomous.contact",
"description": "An experiment.",
"version": "0.0.1",
"license": "MIT",
"exports": {},
"tasks": {
"lint": "deno lint",
"fmt": "deno fmt",
"serve": "FSDB_ROOT=$PWD/.fsdb deno --allow-env --allow-read --allow-write --allow-net jsr:@andyburke/serverus --root ./public"
},
"test": {
"exclude": ["tests/data/"]
},
"fmt": {
"include": ["**/*.ts"],
"options": {
"useTabs": true,
"lineWidth": 140,
"indentWidth": 4,
"singleQuote": true,
"proseWrap": "preserve",
"trailingCommas": "never"
}
},
"lint": {
"include": ["**/*.ts"],
"rules": {
"tags": ["recommended"],
"exclude": ["no-explicit-any"]
}
},
"imports": {
"@andyburke/fsdb": "jsr:@andyburke/fsdb@^0.4.0",
"@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0",
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.0.12",
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@std/path": "jsr:@std/path@^1.1.0",
"@stdext/crypto": "jsr:@stdext/crypto@^0.1.0"
}
}

92
deno.lock generated Normal file
View file

@ -0,0 +1,92 @@
{
"version": "5",
"specifiers": {
"jsr:@andyburke/fsdb@0.4": "0.4.0",
"jsr:@andyburke/lurid@0.2": "0.2.0",
"jsr:@andyburke/serverus@^0.0.12": "0.0.12",
"jsr:@std/async@^1.0.13": "1.0.13",
"jsr:@std/cli@^1.0.19": "1.0.20",
"jsr:@std/cli@^1.0.20": "1.0.20",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.6": "1.0.8",
"jsr:@std/fs@^1.0.14": "1.0.18",
"jsr:@std/fs@^1.0.18": "1.0.18",
"jsr:@std/http@^1.0.13": "1.0.17",
"jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/path@^1.0.8": "1.1.0",
"jsr:@std/path@^1.1.0": "1.1.0",
"jsr:@stdext/crypto@0.1": "0.1.0"
},
"jsr": {
"@andyburke/fsdb@0.4.0": {
"integrity": "13ff46528835e6eaf5ff57fcd1bdd97020d608e6d1e03a38be0d162d1bbbace1",
"dependencies": [
"jsr:@std/cli@^1.0.20",
"jsr:@std/fs@^1.0.18",
"jsr:@std/path@^1.1.0"
]
},
"@andyburke/lurid@0.2.0": {
"integrity": "c5b51e56ef8457b9ef56c060bd9db817a90d8e4784506e348110900286574ce5",
"dependencies": [
"jsr:@std/cli@^1.0.19"
]
},
"@andyburke/serverus@0.0.12": {
"integrity": "051cbffd30577e39ca604e009c3870c4b32b7e4118f2da58fc18ec05afa5b5bb",
"dependencies": [
"jsr:@std/async",
"jsr:@std/cli@^1.0.19",
"jsr:@std/fmt",
"jsr:@std/fs@^1.0.14",
"jsr:@std/http",
"jsr:@std/media-types",
"jsr:@std/path@^1.0.8"
]
},
"@std/async@1.0.13": {
"integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96"
},
"@std/cli@1.0.20": {
"integrity": "a8c384a2c98cec6ec6a2055c273a916e2772485eb784af0db004c5ab8ba52333"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
},
"@std/fs@1.0.18": {
"integrity": "24bcad99eab1af4fde75e05da6e9ed0e0dce5edb71b7e34baacf86ffe3969f3a",
"dependencies": [
"jsr:@std/path@^1.1.0"
]
},
"@std/http@1.0.17": {
"integrity": "98aec8ab4080d95c21f731e3008f69c29c5012d12f1b4e553f85935db601569f"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/path@1.1.0": {
"integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886"
},
"@stdext/crypto@0.1.0": {
"integrity": "05dc9e754c2529574d8bf98bd40c7dc468a02dcb2fa5e8644fff6813ceab66a4",
"dependencies": [
"jsr:@std/encoding@1"
]
}
},
"workspace": {
"dependencies": [
"jsr:@andyburke/fsdb@0.4",
"jsr:@andyburke/lurid@0.2",
"jsr:@andyburke/serverus@^0.0.12",
"jsr:@std/encoding@^1.0.10",
"jsr:@std/path@^1.1.0",
"jsr:@stdext/crypto@0.1"
]
}
}

16
models/password_entry.ts Normal file
View file

@ -0,0 +1,16 @@
import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb';
export type PASSWORD_ENTRY = {
user_id: string;
hash: string;
salt: string;
timestamps: {
created: string;
updated: string;
};
};
export const PASSWORD_ENTRY_STORE = new FSDB_COLLECTION<PASSWORD_ENTRY>({
name: 'password_entries',
id_field: 'user_id'
});

25
models/session.ts Normal file
View file

@ -0,0 +1,25 @@
import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb';
import { FSDB_INDEXER_SYMLINKS } from 'jsr:@andyburke/fsdb/indexers';
import { by_lurid } from 'jsr:@andyburke/fsdb/organizers';
export type SESSION = {
id: string;
user_id: string;
secret: string;
timestamps: {
created: string;
expires: string;
ended: string;
};
};
export const SESSIONS = new FSDB_COLLECTION<SESSION>({
name: 'sessions',
indexers: {
user_id: new FSDB_INDEXER_SYMLINKS<SESSION>({
name: 'user_id',
field: 'user_id',
organize: by_lurid
})
}
});

15
models/totp_entry.ts Normal file
View file

@ -0,0 +1,15 @@
import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb';
export type TOTP_ENTRY = {
user_id: string;
secret: string;
timestamps: {
created: string;
updated: string;
};
};
export const TOTP_ENTRY_STORE = new FSDB_COLLECTION<TOTP_ENTRY>({
name: 'totp_entries',
id_field: 'user_id'
});

37
models/user.ts Normal file
View file

@ -0,0 +1,37 @@
import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb';
import { FSDB_INDEXER_SYMLINKS } from 'jsr:@andyburke/fsdb/indexers';
import { by_character } from 'jsr:@andyburke/fsdb/organizers';
export type USER = {
id: string;
username: string;
timestamps: {
created: string;
updated: string;
};
};
export const USER_STORE = new FSDB_COLLECTION<USER>({
name: 'users',
indexers: {
// email: new FSDB_INDEXER_SYMLINKS<USER>({
// name: 'email',
// field: 'email',
// organize: by_email
// }),
username: new FSDB_INDEXER_SYMLINKS<USER>({
name: 'username',
field: 'username',
organize: by_character
}),
normalized_username: new FSDB_INDEXER_SYMLINKS<USER>({
name: 'normalized_username',
get_values_to_index: (user) => {
return [user.username.toLowerCase()];
},
organize: by_character
})
}
});

View file

@ -0,0 +1,15 @@
import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb';
export type USER_PERMISSIONS = {
user_id: string;
permissions: string[];
timestamps: {
created: string;
updated: string;
};
};
export const USER_PERMISSIONS_STORE = new FSDB_COLLECTION<USER_PERMISSIONS>({
name: 'user_permissions',
id_field: 'user_id'
});

16
public/api/auth/README.md Normal file
View file

@ -0,0 +1,16 @@
# /api/auth
Authentication for the service.
## POST /api/auth
Log into the service.
```
{
email?: string; // either email or username must be specified
username?: string;
password_hash: string; //should be a base64-encoded SHA-256 hash of the user's client-entered pw
totp?: string; // TOTP if configured on account
}
```

192
public/api/auth/index.ts Normal file
View file

@ -0,0 +1,192 @@
import { PASSWORD_ENTRY_STORE } from '../../../models/password_entry.ts';
import { USER, USER_STORE } from '../../../models/user.ts';
import { generateSecret } from 'jsr:@stdext/crypto/utils';
import { encodeBase32 } from 'jsr:@std/encoding';
import { verify } from 'jsr:@stdext/crypto/hash';
import lurid from 'jsr:@andyburke/lurid';
import { SESSION, SESSIONS } from '../../../models/session.ts';
import { TOTP_ENTRY_STORE } from '../../../models/totp_entry.ts';
import { verifyTotp } from 'jsr:@stdext/crypto/totp';
import { encodeBase64 } from 'jsr:@std/encoding/base64';
import parse_body from '../../../utils/bodyparser.ts';
const DEFAULT_SESSION_TIME: number = 60 * 60; // 1 Hour
// POST /api/auth - Authenticate
export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
try {
const body = await parse_body(req);
const email: string = body.email?.toLowerCase().trim() ?? '';
const username: string = body.username?.toLowerCase() ?? '';
const password: string | undefined = body.password;
const password_hash: string | undefined = body.password_hash ?? (typeof password === 'string'
? encodeBase64(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password ?? ''))
)
: undefined);
const totp: string | undefined = body.totp;
if ((!email.length && !username.length) || (email.length && username.length)) {
return Response.json({
error: {
message: 'You must specify either an email or username to log in.',
cause: 'email_or_username_required'
}
}, {
status: 400
});
}
// password has should be a base64-encoded SHA-256 hash
if (typeof password_hash !== 'string') {
return Response.json({
error: {
message: 'invalid password hash',
cause: 'invalid_password_hash'
}
}, {
status: 400
});
}
let user: USER | undefined = undefined;
if (email.length) {
user = (await USER_STORE.find({
email
})).shift();
} else if (username.length) {
user = (await USER_STORE.find({
username
})).shift();
}
if (!user) {
return Response.json({
error: {
message: 'Could not locate an account with this email or username.',
cause: 'missing_account'
}
}, {
status: 400
});
}
const password_entry = await PASSWORD_ENTRY_STORE.get(user.id);
if (!password_entry) {
return Response.json({
error: {
message: 'Missing password entry for this account, please contact support.',
cause: 'missing_password_entry'
}
}, {
status: 500
});
}
const verified = verify('bcrypt', `${password_hash}${password_entry.salt}`, password_entry.hash);
if (!verified) {
return Response.json({
error: {
message: 'Incorrect password.',
cause: 'incorrect_password'
}
}, {
status: 400
});
}
const totp_entry = await TOTP_ENTRY_STORE.get(user.id);
if (totp_entry) {
if (typeof totp !== 'string' || !totp.length) {
return Response.json({
two_factor: true,
totp: true
}, {
status: 202,
headers: {
'Set-Cookie': `checklist_observer_totp_user_id=${user.id}; Path=/; Max-Age=300`
}
});
}
const valid_totp: boolean = await verifyTotp(totp, totp_entry.secret);
if (!valid_totp) {
return Response.json({
error: {
message: 'Incorrect TOTP.',
cause: 'incorrect_totp'
}
}, {
status: 400
});
}
}
const session_result: SESSION_RESULT = await get_session({
user,
expires: body.session?.expires
});
// TODO: verify this redirect is relative?
const headers = session_result.headers;
let status = 201;
if (typeof meta?.query?.redirect === 'string') {
const url = new URL(req.url);
session_result.headers.append('location', `${url.origin}${meta.query.redirect}`);
status = 302;
}
return Response.json({
user,
session: session_result.session
}, {
status,
headers
});
} catch (error) {
return Response.json({
error: {
message: (error as Error).message ?? 'Unknown Error!',
cause: (error as Error).cause ?? 'unknown'
}
}, { status: 400 });
}
}
export type SESSION_RESULT = {
session: SESSION;
headers: Headers;
};
export type SESSION_INFO = {
user: USER;
expires: string | undefined;
};
export async function get_session(session_settings: SESSION_INFO): Promise<SESSION_RESULT> {
const now = new Date().toISOString();
const expires: string = session_settings.expires ??
new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toISOString();
const session: SESSION = {
id: lurid(),
user_id: session_settings.user.id,
secret: encodeBase32(generateSecret()),
timestamps: {
created: now,
expires,
ended: ''
}
};
await SESSIONS.create(session);
const headers = new Headers();
headers.append('Set-Cookie', `checklist_observer_session_id=${session.id}; Path=/; Expires=${expires}`);
return {
session,
headers
};
}

View file

@ -0,0 +1,149 @@
import { PASSWORD_ENTRY, PASSWORD_ENTRY_STORE } from '../../../../models/password_entry.ts';
import { SESSIONS } from '../../../../models/session.ts';
import { USER, USER_STORE } from '../../../../models/user.ts';
import { USER_PERMISSIONS, USER_PERMISSIONS_STORE } from '../../../../models/user_permissions.ts';
import parse_body from '../../../../utils/bodyparser.ts';
export const PERMISSIONS: Record<string, (req: Request, meta: Record<string, any>) => Promise<boolean>> = {};
// GET /api/users/:id - Get single user
PERMISSIONS.GET = (_req: Request, meta: Record<string, any>): Promise<boolean> => {
const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id;
const can_read_self = meta.user_permissions?.permissions.includes('self.read');
const can_read_others = meta.user_permissions?.permissions?.includes('users.read');
return can_read_others || (can_read_self && user_is_self);
};
export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> {
const user_id: string = meta.params?.id?.toLowerCase().trim() ?? '';
const user: USER | null = user_id.length === 49 ? await USER_STORE.get(user_id) : null; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
if (!user) {
return Response.json({
error: {
message: `Could not locate a user with id: "${user_id}"`,
cause: 'unknown_user'
}
}, {
status: 404
});
}
const user_is_self = meta.user?.id === user.id;
const has_permission_to_read = (user_is_self && meta.user_permissions?.permissions?.includes('self.read')) ||
(meta.user_permissions?.permissions?.includes('users.read'));
if (!has_permission_to_read) {
return Response.json({
error: {
message: 'Permission denied.',
cause: 'permission_denied'
}
}, {
status: 400
});
}
return Response.json(user, {
status: 200
});
}
// PUT /api/users/:id - Update user
PERMISSIONS.PUT = (_req: Request, meta: Record<string, any>): Promise<boolean> => {
const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id;
const can_write_self = meta.user_permissions?.permissions.includes('self.write');
const can_write_others = meta.user_permissions?.permissions?.includes('users.write');
return can_write_others || (can_write_self && user_is_self);
};
export async function PUT(req: Request, meta: { params: Record<string, any> }): Promise<Response> {
const now = new Date().toISOString();
const id: string = meta.params.id ?? '';
const existing = await USER_STORE.get(id);
if (!existing) {
return Response.json({
error: {
message: 'User not found',
cause: 'unknown_user'
}
}, {
status: 404
});
}
try {
const body = await parse_body(req);
const updated = {
...existing,
username: body.username || existing.username,
timestamps: {
created: existing.timestamps.created,
updated: now
}
};
await USER_STORE.update(updated);
return Response.json(updated, {
status: 200
});
} catch (err) {
return Response.json({
error: {
message: (err as Error)?.message ?? 'Unknown error due to invalid user data.',
cause: (err as Error)?.cause ?? 'invalid_user_data'
}
}, {
status: 400
});
}
}
// DELETE /api/users/:id - Delete user
PERMISSIONS.DELETE = (_req: Request, meta: Record<string, any>): Promise<boolean> => {
const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id;
const can_write_self = meta.user_permissions?.permissions.includes('self.write');
const can_write_others = meta.user_permissions?.permissions?.includes('users.write');
return can_write_others || (can_write_self && user_is_self);
};
export async function DELETE(_req: Request, meta: { params: Record<string, any> }): Promise<Response> {
const user_id: string = meta.params.id ?? '';
const user: USER | null = await USER_STORE.get(user_id);
if (!user) {
return Response.json({
error: {
message: 'Error deleting user.',
cause: 'unknown_user'
}
}, {
status: 404
});
}
const password_entry: PASSWORD_ENTRY | null = await PASSWORD_ENTRY_STORE.get(user_id);
if (password_entry) {
await PASSWORD_ENTRY_STORE.delete(password_entry);
}
const user_permissions: USER_PERMISSIONS | null = await USER_PERMISSIONS_STORE.get(user_id);
if (user_permissions) {
await USER_PERMISSIONS_STORE.delete(user_permissions);
}
const sessions = await SESSIONS.find({
user_id
});
for (const session of sessions) {
await SESSIONS.delete(session);
}
await USER_STORE.delete(user);
return Response.json({
deleted: true
}, {
status: 200
});
}

164
public/api/users/index.ts Normal file
View file

@ -0,0 +1,164 @@
import { PASSWORD_ENTRY, PASSWORD_ENTRY_STORE } from '../../../models/password_entry.ts';
import { USER, USER_STORE } from '../../../models/user.ts';
import { USER_PERMISSIONS, USER_PERMISSIONS_STORE } from '../../../models/user_permissions.ts';
import { generateSecret } from 'jsr:@stdext/crypto/utils';
import { hash } from 'jsr:@stdext/crypto/hash';
import lurid from 'jsr:@andyburke/lurid';
import { encodeBase64 } from 'jsr:@std/encoding';
import parse_body from '../../../utils/bodyparser.ts';
import { get_session, SESSION_RESULT } from '../auth/index.ts';
// TODO: figure out a better solution for doling out permissions
const DEFAULT_USER_PERMISSIONS: string[] = [
'self.read',
'self.write',
'checklists.read',
'checklists.write',
'checklists.events.read',
'checklists.events.write'
];
export const PERMISSIONS: Record<string, (req: Request, meta: Record<string, any>) => Promise<boolean>> = {};
// GET /api/users - get users
// query parameters:
// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted)
PERMISSIONS.GET = (_req: Request, meta: Record<string, any>): Promise<boolean> => {
const can_read_others = meta.user_permissions?.permissions?.includes('users.read');
return can_read_others;
};
export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> {
const query: URLSearchParams = meta.query;
const partial_id: string | undefined = query.get('partial_id')?.toLowerCase().trim();
const has_partial_id = typeof partial_id === 'string' && partial_id.length >= 2;
if (!has_partial_id) {
return Response.json({
error: {
message: 'You must specify a `partial_id` query parameter.',
cause: 'missing_query_parameter'
}
}, {
status: 400
});
}
const limit = Math.min(parseInt(query.get('limit') ?? '10'), 100);
const users = await USER_STORE.find({
id: partial_id
}, {
limit
});
return Response.json(users, {
status: 200
});
}
// POST /api/users - Create user
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 username: string = body.username?.trim() ?? '';
const normalized_username = username.toLowerCase();
const existing_user_with_username = (await USER_STORE.find({
normalized_username
})).shift();
if (existing_user_with_username) {
return Response.json({
error: {
cause: 'username_conflict',
message: 'Username is already in use.'
}
}, {
status: 400
});
}
const password_hash: string = body.password_hash ?? (typeof body.password === 'string'
? encodeBase64(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body.password))
)
: '');
if (password_hash.length < 32) {
return Response.json({
error: {
cause: 'invalid password hash',
message: 'Password must be hashed with a stronger algorithm.'
}
}, {
status: 400
});
}
const user: USER = {
id: lurid(),
username,
timestamps: {
created: now,
updated: now
}
};
await USER_STORE.create(user);
const salt = generateSecret();
const hashed_password_value = hash('bcrypt', `${password_hash}${salt}`);
const password_entry: PASSWORD_ENTRY = {
user_id: user.id,
hash: hashed_password_value,
salt,
timestamps: {
created: now,
updated: now
}
};
await PASSWORD_ENTRY_STORE.create(password_entry);
const user_permissions: USER_PERMISSIONS = {
user_id: user.id,
permissions: DEFAULT_USER_PERMISSIONS,
timestamps: {
created: now,
updated: now
}
};
await USER_PERMISSIONS_STORE.create(user_permissions);
const session_result: SESSION_RESULT = await get_session({
user,
expires: undefined
});
// TODO: verify this redirect is ok?
const headers = session_result.headers;
let status = 201;
if (typeof meta?.query?.redirect === 'string') {
const url = new URL(req.url);
headers.append('location', `${url.origin}${meta.query.redirect}`);
status = 302;
}
return Response.json({
user,
session: session_result.session
}, {
status,
headers
});
} catch (error) {
return Response.json({
error: {
message: (error as Error).message ?? 'Unknown Error!',
cause: (error as Error).cause ?? 'unknown'
}
}, { status: 500 });
}
}

819
public/index.html Normal file
View file

@ -0,0 +1,819 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Social UX</title>
<style>
/* Dark mode default */
:root {
--bg: #121212;
--text: #f0f0f0;
--accent: #4caf50;
--icon-scale: 1.25;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f0f0f0;
--text: #121212;
--accent: #4caf50;
}
}
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Remove default margin in favour of better control in authored CSS */
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin-block-end: 0;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role="list"],
ol[role="list"] {
list-style: none;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: 1.5;
}
/* Set shorter line heights on headings and interactive elements */
h1,
h2,
h3,
h4,
button,
input,
label {
line-height: 1.1;
}
/* Balance text wrapping on headings */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: currentColor;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font-family: inherit;
font-size: inherit;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 5ex;
}
* {
margin: 0;
padding: 0;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
body {
font-family: sans-serif;
color: var(--text);
background-color: var(--bg);
display: flex;
flex-direction: column;
height: 100vh; // fixed height?
}
.tabs-container {
position: relative;
height: 100%;
}
.tabs {
position: relative;
height: 100%;
width: 100%;
}
.tabs::before,
.tabs::after {
content: "";
display: table;
}
.tabs::after {
clear: both;
}
.tab {
float: left;
}
.tab-switch {
display: none;
}
.tab-label {
position: relative;
width: 8rem;
height: 5rem;
cursor: pointer;
transition: all 0.25s;
display: flex;
align-items: center;
justify-content: end;
flex-direction: column;
}
.tab-label .label {
margin: 0.75rem 0;
}
.tab-content {
position: absolute;
z-index: 1;
top: 5rem;
left: 0;
right: 0;
bottom: 0;
padding: 1rem 1rem 0 1rem;
opacity: 0;
transition: all 0.35s;
}
.tab-switch:checked + .tab-label {
margin-top: 1px;
border-bottom: 1px solid #888;
transition: all 0.35s;
z-index: 1;
}
.tab-switch:checked + label + .tab-content {
z-index: 2;
opacity: 1;
transition: all 0.35s;
}
@media screen and (max-width: 800px) {
.tab-label {
width: 4rem;
}
.tab-label .label {
font-size: small;
}
}
@media screen and (max-width: 480px) {
.tab-label {
width: 2.75rem;
}
.tab-label .label {
font-size: x-small;
}
}
.resizable {
resize: horizontal;
overflow: hidden;
border-right: 4px solid #444;
}
/* ICONS */
.icon {
width: 24px;
height: 24px;
transform: scale(var(--icon-scale, 1));
stroke: white;
fill: transparent;
stroke-width: 1pt;
stroke-miterlimit: 10;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 400;
}
/* ICON - CALENDAR */
.icon.calendar,
.icon.calendar::before {
display: block;
box-sizing: border-box;
}
.icon.calendar {
position: relative;
transform: scale(var(--icon-scale, 1));
width: 18px;
height: 18px;
border: 2px solid;
border-top: 4px solid;
border-radius: 3px;
}
.icon.calendar::before {
content: "";
position: absolute;
width: 10px;
border-radius: 3px;
left: 2px;
background: currentColor;
height: 2px;
top: 2px;
}
/* ICON - EXCHANGE */
.icon.exchange,
.icon.exchange::after,
.icon.exchange::before {
display: block;
box-sizing: border-box;
width: 8px;
height: 8px;
}
.icon.exchange {
position: relative;
transform: scale(var(--icon-scale, 1));
box-shadow:
-3px 3px 0 -1px,
3px -3px 0 -1px;
}
.icon.exchange::after,
.icon.exchange::before {
content: "";
position: absolute;
border: 2px solid;
}
.icon.exchange::before {
top: -5px;
left: -5px;
}
.icon.exchange::after {
bottom: -5px;
right: -5px;
}
/* ICON - HOME */
.icon.home {
background:
linear-gradient(to left, currentColor 5px, transparent 0) no-repeat 0 bottom/4px
2px,
linear-gradient(to left, currentColor 5px, transparent 0) no-repeat right
bottom/4px 2px;
box-sizing: border-box;
position: relative;
display: block;
transform: scale(var(--icon-scale, 1));
width: 18px;
height: 14px;
border: 2px solid;
border-top: 0;
border-bottom: 0;
border-top-right-radius: 3px;
border-top-left-radius: 3px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
margin-bottom: -2px;
}
.icon.home::after,
.icon.home::before {
content: "";
display: block;
box-sizing: border-box;
position: absolute;
}
.icon.home::before {
border-top: 2px solid;
border-left: 2px solid;
border-top-left-radius: 4px;
transform: rotate(45deg);
top: -5px;
border-radius: 3px;
width: 14px;
height: 14px;
left: 0;
}
.icon.home::after {
width: 8px;
height: 10px;
border: 2px solid;
border-radius: 100px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 0;
left: 3px;
bottom: 0;
}
/* ICON - RESOURCES */
.icon.resources,
.icon.resources::after {
display: block;
box-sizing: border-box;
border-radius: 22px;
}
.icon.resources {
position: relative;
transform: scale(var(--icon-scale, 1));
width: 20px;
height: 20px;
border: 2px solid transparent;
}
.icon.resources::after {
content: "";
position: absolute;
width: 4px;
height: 4px;
background: currentColor;
top: 6px;
left: 6px;
box-shadow:
0 7px 0 1px,
0 -7px 0 1px,
-7px 0 0 1px,
7px 0 0 1px;
}
/* ICON - TALK */
.icon.talk {
transform: scale(var(--icon-scale, 1));
}
.icon.talk,
.icon.talk::after {
box-sizing: border-box;
position: relative;
display: block;
width: 20px;
height: 20px;
border-radius: 100px;
border: 2px dotted currentColor;
}
.icon.talk::after {
content: "";
position: absolute;
width: 8px;
height: 8px;
border: 1px solid transparent;
top: 4px;
left: 4px;
box-shadow:
0 0 0 2px,
inset 0 0 0 2px currentColor;
}
/* ICON - USER */
.icon.user,
.icon.user::after,
.icon.user::before {
display: block;
box-sizing: border-box;
border: 2px solid;
border-radius: 100px;
}
.icon.user {
overflow: hidden;
transform: scale(var(--icon-scale, 1));
width: 22px;
height: 22px;
position: relative;
}
.icon.user::after,
.icon.user::before {
content: "";
position: absolute;
top: 2px;
left: 5px;
width: 8px;
height: 8px;
}
.icon.user::after {
border-radius: 200px;
top: 11px;
left: 0px;
width: 18px;
height: 18px;
}
/* ICON - WORK */
.icon.work {
box-sizing: border-box;
position: relative;
display: block;
transform: scale(var(--icon-scale, 1));
width: 20px;
height: 20px;
border: 2px solid;
border-radius: 22px;
}
.icon.work::after,
.icon.work::before {
content: "";
display: block;
box-sizing: border-box;
position: absolute;
}
.icon.work::before {
width: 12px;
height: 6px;
border: 2px solid;
border-top-left-radius: 100px;
border-top-right-radius: 100px;
top: 2px;
left: 2px;
border-bottom: 0;
}
.icon.work::after {
width: 18px;
height: 2px;
background: currentColor;
left: -1px;
top: 8px;
}
</style>
</head>
<body>
<div id="signup-login-wall">
<style>
#signup-login-wall {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
background: var(--bg);
visibility: visible;
opacity: 1;
transition: all 0.33s;
}
body[data-user] #signup-login-wall {
visibility: hidden;
opacity: 0;
}
#signup-login-wall .limiter {
width: 95%;
position: relative;
background: rgba(128, 128, 128, 0.5);
max-width: 40em;
min-height: 22rem;
}
#signup-login-wall form {
width: 100%;
}
form div {
position: relative;
display: flex;
margin-bottom: 1em;
}
form label {
position: absolute;
top: 10px;
font-size: 30px;
margin: 10px;
padding: 0 10px;
background-color: var(--bg);
-webkit-transition:
top 0.2s ease-in-out,
font-size 0.2s ease-in-out;
transition:
top 0.2s ease-in-out,
font-size 0.2s ease-in-out;
}
form input:focus ~ label,
form input:valid ~ label {
top: -25px;
font-size: 20px;
}
form input {
width: 100%;
padding: 20px;
border: 1px solid var(--text);
font-size: 20px;
background-color: var(--bg);
color: var(--text);
}
form input:focus {
outline: none;
}
form button {
width: 100%;
padding: 20px;
border: 1px solid var(--text);
font-size: 20px;
background-color: var(--bg);
color: var(--text);
}
form button.primary {
background-color: var(--accent);
}
</style>
<div class="limiter">
<div class="tabs">
<div id="login-tab" class="tab">
<input
type="radio"
name="signup-login-tabs"
id="login-tab-input"
class="tab-switch"
checked="checked"
/>
<label for="login-tab-input" class="tab-label">
<div class="label">Log In</div>
</label>
<div class="tab-content">
<form
id="login-form"
action="/api/auth"
onreply="(user)=>{ document.body.dataset.user = user; }"
>
<div>
<input
id="login-username"
type="text"
name="username"
required
/>
<label for="login-username">username</label>
</div>
<div>
<input
id="login-password"
type="password"
name="password"
required
/>
<label for="login-password">password</label>
</div>
<div>
<button id="login-submit" type="submit" class="primary">
Log In
</button>
</div>
</form>
</div>
</div>
<div id="signup-tab" class="tab">
<input
type="radio"
name="signup-login-tabs"
id="signup-tab-input"
class="tab-switch"
/>
<label for="signup-tab-input" class="tab-label">
<div class="label">Sign Up</div>
</label>
<div class="tab-content">
<form id="signup-form" action="/api/users">
<script>
const form = document.currentScript.closest("form");
form.on_response = (response) => {
document.body.dataset.user = response.user;
console.dir({ response });
};
</script>
<div>
<input
id="signup-username"
type="text"
name="username"
required
/>
<label for="signup-username">username</label>
</div>
<div>
<input
id="signup-password"
type="password"
name="password"
required
/>
<label for="signup-password">password</label>
</div>
<button id="signup-submit" type="submit" class="primary">
Sign Up
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- MAIN -->
<div class="tabs">
<div id="home" class="tab">
<input
type="radio"
name="top-level-tabs"
id="home-tab-input"
checked="checked"
class="tab-switch"
/>
<label for="home-tab-input" class="tab-label">
<div class="icon home"></div>
<div class="label">Home</div>
</label>
<div class="tab-content">This is the home tab.</div>
</div>
<div id="talk" class="tab">
<input type="radio" name="top-level-tabs" id="talk-tab-input" class="tab-switch" />
<label for="talk-tab-input" class="tab-label"
><div class="icon talk"></div>
<div class="label">Talk</div>
</label>
<div class="tab-content">
<style>
#talk .tab-content {
display: grid;
grid-template-columns: auto 1fr;
}
#talk .sidebar {
min-width: 100px;
}
#talk .room {
padding: 1rem;
}
</style>
<div class="sidebar resizable">
<ul>
<li>#one</li>
<li>#two</li>
<li>#three</li>
</ul>
</div>
<div class="room">This is a talk room.</div>
</div>
</div>
<div id="exchange" class="tab">
<input
type="radio"
name="top-level-tabs"
id="exchange-tab-input"
class="tab-switch"
/>
<label for="exchange-tab-input" class="tab-label"
><div class="icon exchange"></div>
<div class="label">Exchange</div></label
>
<div class="tab-content">This is the exchange tab.</div>
</div>
<div id="work" class="tab">
<input type="radio" name="top-level-tabs" id="work-tab-input" class="tab-switch" />
<label for="work-tab-input" class="tab-label"
><div class="icon work"></div>
<div class="label">Work</div>
</label>
<div class="tab-content">This is the work tab.</div>
</div>
<div id="resources" class="tab">
<input
type="radio"
name="top-level-tabs"
id="resources-tab-input"
class="tab-switch"
/>
<label for="resources-tab-input" class="tab-label"
><div class="icon resources"></div>
<div class="label">Resources</div></label
>
<div class="tab-content">This is the resources tab.</div>
</div>
<div id="calendar" class="tab">
<input
type="radio"
name="top-level-tabs"
id="calendar-tab-input"
class="tab-switch"
/>
<label for="calendar-tab-input" class="tab-label"
><div class="icon calendar"></div>
<div class="label">Calendar</div></label
>
<div class="tab-content">This is the calendar tab.</div>
</div>
<div id="user" class="tab">
<input type="radio" name="top-level-tabs" id="user-tab-input" class="tab-switch" />
<label for="user-tab-input" class="tab-label"
><div class="icon user"></div>
<div class="label">Profile</div></label
>
<div class="tab-content">This is the profile tab.</div>
</div>
</div>
</body>
<script>
document.addEventListener("DOMContentLoaded", () => {
const forms = document.querySelectorAll("form");
for (const form of forms) {
const script = form.querySelector("script");
form.onsubmit = async (event) => {
event.preventDefault();
const form_data = new FormData(form);
const body = Object.fromEntries(form_data.entries());
const url = form.action;
try {
// TODO: send session header
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error_body = await response.json();
const error = error_body?.error;
if (form.on_error) {
return form.on_error(error);
}
alert(error.message ?? "Unknown error!");
return;
}
const response_body = await response.json();
if (form.on_response) {
return form.on_response(response_body);
}
} catch (error) {
console.dir({
error,
});
if (form.onerror) {
return form.onerror(error);
}
alert(error);
}
};
}
});
</script>
</html>

18
utils/bodyparser.ts Normal file
View file

@ -0,0 +1,18 @@
export default async function parse_body(req: Request): Promise<any> {
switch (req.headers.get('content-type')) {
case 'application/x-www-form-urlencoded': {
const form_data = await req.formData();
const body: Record<string, any> = {};
const keys = form_data.keys();
for (const key of keys) {
body[key] = form_data.get(key);
}
return body;
}
case 'application/json':
default:
return req.json();
}
}