266 lines
6.7 KiB
TypeScript
266 lines
6.7 KiB
TypeScript
import {
|
|
Pool,
|
|
PoolClient,
|
|
PostgresError,
|
|
} from "https://deno.land/x/postgres@v0.16.1/mod.ts";
|
|
import {
|
|
type QueryArguments,
|
|
type QueryArrayResult,
|
|
type QueryObjectResult,
|
|
} from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments";
|
|
import { config } from "@/config.ts";
|
|
import * as base64 from "$std/encoding/base64.ts";
|
|
import { log } from "@/log.ts";
|
|
|
|
import {
|
|
type Identifiable,
|
|
type Note,
|
|
type Team,
|
|
type Timestamped,
|
|
type Token,
|
|
type TokenDigest,
|
|
type User,
|
|
} from "@/types.ts";
|
|
|
|
import { sha256 } from "https://denopkg.com/chiefbiiko/sha256@v1.0.0/mod.ts";
|
|
|
|
export { PostgresError };
|
|
export { type QueryObjectResult };
|
|
|
|
const pool = new Pool(config.postgres.url, 3, true);
|
|
|
|
async function dbOp<T>(op: (connection: PoolClient) => Promise<T>) {
|
|
let result = null;
|
|
let exception = null;
|
|
try {
|
|
const connection = await pool.connect();
|
|
try {
|
|
result = await op(connection);
|
|
} catch (err) {
|
|
log.error("Error querying database:", { ...err });
|
|
exception = err;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
} catch (err) {
|
|
exception = err;
|
|
log.error("Error connecting to database:", err);
|
|
}
|
|
if (exception != null) throw exception;
|
|
return result;
|
|
}
|
|
|
|
export async function queryObject<T>(
|
|
sql: string,
|
|
args?: QueryArguments,
|
|
): Promise<QueryObjectResult<T> | null> {
|
|
return await dbOp(async (connection) => {
|
|
const result = await connection.queryObject<T>({
|
|
camelcase: true,
|
|
text: sql.trim(),
|
|
args,
|
|
});
|
|
log.debug(result);
|
|
return result;
|
|
});
|
|
}
|
|
|
|
export async function queryArray<T extends []>(
|
|
sql: string,
|
|
args?: QueryArguments,
|
|
): Promise<QueryArrayResult<T> | null> {
|
|
return await dbOp(async (connection) =>
|
|
await connection.queryArray<T>({
|
|
text: sql.trim(),
|
|
args,
|
|
})
|
|
);
|
|
}
|
|
|
|
export async function listNotes(): Promise<Note[] | null> {
|
|
return someRows(
|
|
await queryObject<Note & User>(
|
|
'select u.username as user_username, u.display_name as user_display_name, n.* from note n left join "user" u on u.id = n.user_id order by n.created_at desc',
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getNote(
|
|
id: string | { id: string },
|
|
): Promise<Note | null> {
|
|
const idVal = typeof id == "object" ? id.id : id;
|
|
log.debug("getNote id =", JSON.stringify(idVal));
|
|
return singleRow(
|
|
await queryObject<Note>(
|
|
"select * from note where id = $1",
|
|
[idVal],
|
|
),
|
|
);
|
|
}
|
|
|
|
type Ungenerated<T> = Omit<T, keyof Identifiable | keyof Timestamped>;
|
|
|
|
export async function createNote(
|
|
{ content, userId }: Ungenerated<Note>,
|
|
): Promise<Note | null> {
|
|
return singleRow(
|
|
await queryObject<Note>(
|
|
"insert into note (content, user_id) values ($1, $2) returning *",
|
|
[content, userId],
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function createUser(
|
|
{ username, passwordDigest }: Ungenerated<User>,
|
|
): Promise<[User | null, Team | null] | null> {
|
|
const result = singleRow(
|
|
await queryObject<{ teamId: string; userId: string }>(
|
|
`
|
|
with new_user as (
|
|
insert into "user" (username, password_digest)
|
|
values ($username, $passwordDigest)
|
|
returning id as user_id
|
|
), new_team as (
|
|
insert into "team" (display_name)
|
|
values ($teamName)
|
|
returning id as team_id
|
|
)
|
|
insert into "team_user" (user_id, team_id, status)
|
|
values (
|
|
(select user_id from new_user),
|
|
(select team_id from new_team),
|
|
'owner'
|
|
) returning user_id, team_id
|
|
`,
|
|
{ username, passwordDigest, teamName: `${username}'s First Team` },
|
|
),
|
|
);
|
|
if (!result) return null;
|
|
const { userId, teamId } = result;
|
|
return await Promise.all([
|
|
getUser({ id: userId }),
|
|
getTeam({ id: teamId }),
|
|
]);
|
|
}
|
|
|
|
const TOKEN_SIZE = 32;
|
|
|
|
export async function createToken(
|
|
token: Omit<Ungenerated<Token>, "digest">,
|
|
): Promise<Token | null> {
|
|
const intermediateToken: Partial<Token> = { ...token };
|
|
if (!intermediateToken.bytes) {
|
|
intermediateToken.bytes = new Uint8Array(TOKEN_SIZE);
|
|
crypto.getRandomValues(intermediateToken.bytes);
|
|
}
|
|
if (!intermediateToken.digest) {
|
|
const digest = sha256(intermediateToken.bytes);
|
|
if (!(digest instanceof Uint8Array)) throw "token digest was non-brinary";
|
|
intermediateToken.digest = digest;
|
|
}
|
|
log.debug(
|
|
`intermediateToken bytes: ${base64.encode(intermediateToken.bytes)}`,
|
|
);
|
|
log.debug(
|
|
`intermediateToken digest: ${base64.encode(intermediateToken.digest)}`,
|
|
);
|
|
if (!intermediateToken.data) intermediateToken.data = null;
|
|
const result = singleRow(
|
|
await queryObject<Token>(
|
|
`
|
|
insert into "user_token" (digest, user_id, data)
|
|
values ($digest, $userId, $data)
|
|
returning *
|
|
`,
|
|
intermediateToken,
|
|
),
|
|
);
|
|
if (result) return { ...intermediateToken, ...result };
|
|
return null;
|
|
}
|
|
|
|
export async function deleteToken(
|
|
token: TokenDigest,
|
|
) {
|
|
const digest = sha256(base64.decode(token));
|
|
return await queryObject(
|
|
`
|
|
delete from user_token where digest = $1
|
|
`,
|
|
[digest],
|
|
);
|
|
}
|
|
|
|
export async function getToken(token: TokenDigest): Promise<Token | null> {
|
|
const digest = sha256(base64.decode(token));
|
|
return singleRow(
|
|
await queryObject(
|
|
`
|
|
select * from user_token where digest = $1
|
|
`,
|
|
[digest],
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getUser(
|
|
{ id, username }: Partial<User>,
|
|
): Promise<User | null> {
|
|
if (!id && !username) throw "getUser called without id or username";
|
|
const column = id ? "id" : "username";
|
|
return singleRow(
|
|
await queryObject<User>(
|
|
`select * from "user" where "${column}" = $1`,
|
|
[id || username],
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getUserFromNonExpiredLoginToken(
|
|
token: TokenDigest,
|
|
): Promise<User | null> {
|
|
// TODO: if the token has expired, return a specific error?
|
|
const digest = sha256(base64.decode(token));
|
|
return singleRow(
|
|
await queryObject<User>(
|
|
`
|
|
select u.* from "user_token" ut
|
|
left join "user" u on u.id = ut.user_id
|
|
where ut."digest" = $1
|
|
and ut."data"->>'type' = 'login'
|
|
and now() < (ut.created_at + '14 days'::interval)
|
|
`,
|
|
[digest],
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getTeam(
|
|
{ id }: Partial<Team>,
|
|
): Promise<Team | null> {
|
|
return singleRow(
|
|
await queryObject<Team>(
|
|
`select * from "team" where "id" = $1`,
|
|
[id],
|
|
),
|
|
);
|
|
}
|
|
|
|
function someRows<T>(result: { rows: T[] } | null): T[] | null {
|
|
log.debug(result);
|
|
if (!result || result.rows.length < 1) return null;
|
|
else return result.rows;
|
|
}
|
|
|
|
function singleRow<T>(result: { rows: T[] } | null): T | null {
|
|
if (!result || result.rows.length < 1) return null;
|
|
else if (result.rows.length > 1) {
|
|
log.error(
|
|
"This singleRow result brought back more than 1 row:",
|
|
result,
|
|
);
|
|
return null;
|
|
} else return result.rows[0];
|
|
}
|