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(op: (connection: PoolClient) => Promise) { 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( sql: string, args?: QueryArguments, ): Promise | null> { return await dbOp(async (connection) => { const result = await connection.queryObject({ camelcase: true, text: sql.trim(), args, }); log.debug(result); return result; }); } export async function queryArray( sql: string, args?: QueryArguments, ): Promise | null> { return await dbOp(async (connection) => await connection.queryArray({ text: sql.trim(), args, }) ); } export async function listNotes(): Promise { return someRows( await queryObject( '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 { const idVal = typeof id == "object" ? id.id : id; log.debug("getNote id =", JSON.stringify(idVal)); return singleRow( await queryObject( "select * from note where id = $1", [idVal], ), ); } type Ungenerated = Omit; export async function createNote( { content, userId }: Ungenerated, ): Promise { return singleRow( await queryObject( "insert into note (content, user_id) values ($1, $2) returning *", [content, userId], ), ); } export async function createUser( { username, passwordDigest }: Ungenerated, ): 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, "digest">, ): Promise { const intermediateToken: Partial = { ...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( ` 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 { 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, ): Promise { if (!id && !username) throw "getUser called without id or username"; const column = id ? "id" : "username"; return singleRow( await queryObject( `select * from "user" where "${column}" = $1`, [id || username], ), ); } export async function getUserFromNonExpiredLoginToken( token: TokenDigest, ): Promise { // TODO: if the token has expired, return a specific error? const digest = sha256(base64.decode(token)); return singleRow( await queryObject( ` 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, ): Promise { return singleRow( await queryObject( `select * from "team" where "id" = $1`, [id], ), ); } function someRows(result: { rows: T[] } | null): T[] | null { log.debug(result); if (!result || result.rows.length < 1) return null; else return result.rows; } function singleRow(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]; }