feature: initial commit
This commit is contained in:
commit
2c27f003c9
15 changed files with 1601 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
data/
|
||||||
|
.fsdb
|
0
README.md
Normal file
0
README.md
Normal file
41
deno.json
Normal file
41
deno.json
Normal 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
92
deno.lock
generated
Normal 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
16
models/password_entry.ts
Normal 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
25
models/session.ts
Normal 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
15
models/totp_entry.ts
Normal 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
37
models/user.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
15
models/user_permissions.ts
Normal file
15
models/user_permissions.ts
Normal 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
16
public/api/auth/README.md
Normal 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
192
public/api/auth/index.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
149
public/api/users/:id/index.ts
Normal file
149
public/api/users/:id/index.ts
Normal 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
164
public/api/users/index.ts
Normal 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
819
public/index.html
Normal 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
18
utils/bodyparser.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue