479 lines
11 KiB
TypeScript
479 lines
11 KiB
TypeScript
import {
|
|
Pool,
|
|
PoolClient,
|
|
PostgresError,
|
|
Transaction,
|
|
type TransactionOptions,
|
|
} 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 { type TeamUserStatus } from "@/types.ts";
|
|
import * as base64 from "$std/encoding/base64.ts";
|
|
import { log } from "@/log.ts";
|
|
|
|
import { type PostgresConfig } from "@/config.ts";
|
|
import {
|
|
type Display,
|
|
type Note,
|
|
type Playlist,
|
|
type Team,
|
|
type TeamUser,
|
|
type Token,
|
|
type TokenDigest,
|
|
type Ungenerated,
|
|
type User,
|
|
} from "@/types.ts";
|
|
|
|
import { sha256 } from "https://denopkg.com/chiefbiiko/sha256@v1.0.0/mod.ts";
|
|
|
|
export { PostgresError };
|
|
export { type QueryObjectResult };
|
|
|
|
let pool: Pool;
|
|
|
|
export function initDatabaseConnectionPool({ url }: PostgresConfig) {
|
|
pool = new Pool(url, 3, true);
|
|
testDbConnection();
|
|
}
|
|
|
|
/*
|
|
* Checks that a certain SQL predicate fetches a row that indeed exists.
|
|
*
|
|
* sqlSnippet should assume it comes after a 'select * from'. For example: '"user" where id = 1'.
|
|
*/
|
|
/*
|
|
async function rowExists(
|
|
sqlSnippet: string,
|
|
args: unknown[],
|
|
): Promise<boolean> {
|
|
const result = await queryArray<[boolean]>(
|
|
`select exists(select 1 from ${sqlSnippet});`,
|
|
args,
|
|
);
|
|
if (result && result.rows.length > 0) {
|
|
return !!(result.rows[0][0]);
|
|
}
|
|
return false;
|
|
}
|
|
*/
|
|
|
|
export async function teamUserStatus(
|
|
userId: string,
|
|
teamId: string,
|
|
): Promise<TeamUserStatus | undefined> {
|
|
try {
|
|
const result = await queryObject<{ status: TeamUserStatus }>(
|
|
"select status from team_user where user_id = $1 and team_id = $2",
|
|
[
|
|
userId,
|
|
teamId,
|
|
],
|
|
);
|
|
return result?.rows[0].status;
|
|
} catch (_e) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export async function testDbConnection(): Promise<boolean> {
|
|
try {
|
|
await dbOp((conn) => conn.queryObject("select 1"));
|
|
log.info("Successfully connected to database");
|
|
return true;
|
|
} catch (e) {
|
|
log.critical("Failed to connect to database:", e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type QueryResult<T> = { rows: T[] } | null;
|
|
|
|
class NoRowsError<T> extends Error {
|
|
result: QueryResult<T>;
|
|
|
|
constructor(result: QueryResult<T>) {
|
|
const message = `No rows in query result: ${result}`;
|
|
super(message);
|
|
this.result = result;
|
|
}
|
|
}
|
|
|
|
class TooManyRowsError<T> extends Error {
|
|
result: QueryResult<T>;
|
|
|
|
constructor(result: QueryResult<T>) {
|
|
const message = `Too many rows in query result: ${result}`;
|
|
super(message);
|
|
this.result = result;
|
|
}
|
|
}
|
|
|
|
function someRows<T>(result: QueryResult<T>): T[] {
|
|
if (!result || result.rows.length < 1) {
|
|
throw new NoRowsError(result);
|
|
} else {
|
|
return result.rows;
|
|
}
|
|
}
|
|
|
|
function singleRow<T>(result: QueryResult<T>): T {
|
|
if (!result || result.rows.length < 1) throw new NoRowsError(result);
|
|
else if (result.rows.length > 1) throw new TooManyRowsError(result);
|
|
else return result.rows[0];
|
|
}
|
|
|
|
export async function dbOp<T>(
|
|
op: (connection: PoolClient) => Promise<T>,
|
|
): Promise<T> {
|
|
let result: T | null = null;
|
|
let exception = null;
|
|
try {
|
|
const connection = await pool.connect();
|
|
try {
|
|
result = await op(connection);
|
|
} catch (err) {
|
|
exception = err;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
} catch (err) {
|
|
log.critical("Error connecting to database:", err);
|
|
exception = err;
|
|
}
|
|
if (exception != null) throw exception;
|
|
if (result == null) {
|
|
throw "Database operation failed to properly load a result";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Example: queryObject('select * from "user" where id = $1', [userId])
|
|
*/
|
|
export async function queryObject<T>(
|
|
sql: string,
|
|
args?: QueryArguments,
|
|
connection?: PoolClient | Transaction,
|
|
): Promise<QueryObjectResult<T> | null> {
|
|
if (!connection) {
|
|
return await dbOp(async (connection) => {
|
|
return await queryObject(sql, args, connection);
|
|
});
|
|
} else {
|
|
const result = await connection.queryObject<T>({
|
|
camelcase: true,
|
|
text: sql.trim(),
|
|
args,
|
|
});
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Example: queryArray('select * from "user" where id = $1', [userId])
|
|
*/
|
|
export async function queryArray<T extends unknown[]>(
|
|
sql: string,
|
|
args?: QueryArguments,
|
|
connection?: PoolClient,
|
|
): Promise<QueryArrayResult<T> | null> {
|
|
if (!connection) {
|
|
return await dbOp(async (connection) => {
|
|
return await queryArray<T>(sql, args, connection);
|
|
});
|
|
} else {
|
|
const result = await connection.queryArray<T>({
|
|
text: sql.trim(),
|
|
args,
|
|
});
|
|
return result;
|
|
}
|
|
}
|
|
|
|
export async function listNotes(): Promise<(Note & User)[]> {
|
|
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> {
|
|
const idVal = typeof id == "object" ? id.id : id;
|
|
return singleRow(
|
|
await queryObject<Note>(
|
|
"select * from note where id = $1",
|
|
[idVal],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 createTeamUser(
|
|
{ teamId, userId, status }: TeamUser,
|
|
transaction?: Transaction,
|
|
): Promise<TeamUser | null> {
|
|
return singleRow(
|
|
await queryObject<TeamUser>(
|
|
`
|
|
insert into "team_user" (user_id, team_id, status)
|
|
values (
|
|
$userId,
|
|
$teamId,
|
|
$status
|
|
) returning *`,
|
|
{ userId, teamId, status },
|
|
transaction,
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function createTeam(
|
|
data: {
|
|
team: Ungenerated<Team>;
|
|
creator?: User;
|
|
},
|
|
transaction?: Transaction,
|
|
): Promise<Team> {
|
|
if (!transaction) {
|
|
return await wrapWithTransaction<Team>(
|
|
"createTeam",
|
|
(t) => createTeam(data, t),
|
|
);
|
|
} else {
|
|
try {
|
|
const { team: { displayName }, creator } = data;
|
|
const team = singleRow(
|
|
await queryObject<Team>(
|
|
`insert into "team" (display_name) values ($displayName) returning *`,
|
|
{ displayName },
|
|
transaction,
|
|
),
|
|
);
|
|
if (creator) {
|
|
await createTeamUser(
|
|
{ teamId: team.id, userId: creator.id, status: "owner" },
|
|
transaction,
|
|
);
|
|
}
|
|
return team;
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function wrapWithTransaction<T>(
|
|
transactionName: string,
|
|
callback: (transaction: Transaction) => Promise<T>,
|
|
transactionOptions?: TransactionOptions,
|
|
): Promise<T> {
|
|
const result = await dbOp<T>(async (connection) => {
|
|
try {
|
|
const transaction = connection.createTransaction(
|
|
transactionName,
|
|
transactionOptions,
|
|
);
|
|
try {
|
|
await transaction.begin();
|
|
const result: T = await callback(transaction);
|
|
await transaction.commit();
|
|
return result;
|
|
} catch (e) {
|
|
await transaction.rollback();
|
|
throw e;
|
|
}
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
});
|
|
if (!result) throw "Failed to finish transactional database operation";
|
|
return result;
|
|
}
|
|
|
|
export async function createUser(
|
|
data: Ungenerated<User>,
|
|
transaction?: Transaction,
|
|
): Promise<User> {
|
|
if (!transaction) {
|
|
return await wrapWithTransaction<User>(
|
|
"createUser",
|
|
(t) => createUser(data, t),
|
|
);
|
|
} else {
|
|
try {
|
|
const { username, passwordDigest } = data;
|
|
const user = singleRow(
|
|
await queryObject<User>(
|
|
`insert into "user" (username, password_digest)
|
|
values ($username, $passwordDigest)
|
|
returning *`,
|
|
{ username, passwordDigest },
|
|
transaction,
|
|
),
|
|
);
|
|
await createTeam({
|
|
team: { displayName: `${username}'s Team` },
|
|
creator: user,
|
|
}, transaction);
|
|
|
|
return user;
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
const TOKEN_SIZE = 32;
|
|
|
|
export async function createToken(
|
|
token: Omit<Ungenerated<Token>, "digest">,
|
|
): Promise<Token> {
|
|
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;
|
|
}
|
|
if (!intermediateToken.data) intermediateToken.data = null;
|
|
const result = singleRow(
|
|
await queryObject<Token>(
|
|
`
|
|
insert into "token" (digest, user_id, data)
|
|
values ($digest, $userId, $data)
|
|
returning *
|
|
`,
|
|
intermediateToken,
|
|
),
|
|
);
|
|
return { ...intermediateToken, ...result };
|
|
}
|
|
|
|
export async function deleteToken(
|
|
token: TokenDigest,
|
|
) {
|
|
const digest = sha256(base64.decode(token));
|
|
return await queryObject(
|
|
`delete from "token" where digest = $1`,
|
|
[digest],
|
|
);
|
|
}
|
|
|
|
export async function getToken(token: TokenDigest): Promise<Token> {
|
|
const digest = sha256(base64.decode(token));
|
|
return singleRow(
|
|
await queryObject(
|
|
`select * from "token" where digest = $1`,
|
|
[digest],
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getUser(
|
|
idOrUsername: { id: string } | { username: string } | string,
|
|
): Promise<User> {
|
|
if (typeof idOrUsername == "string") {
|
|
try {
|
|
return singleRow(
|
|
await queryObject<User>(
|
|
`select * from "user" where "id" = $1`,
|
|
[idOrUsername],
|
|
),
|
|
);
|
|
} catch (_) {
|
|
return singleRow(
|
|
await queryObject<User>(
|
|
`select * from "user" where "username" = $1`,
|
|
[idOrUsername],
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
const column = "id" in idOrUsername ? "id" : "username";
|
|
return singleRow(
|
|
await queryObject<User>(
|
|
`select * from "user" where "${column}" = $1`,
|
|
[(idOrUsername as { id?: string; username?: string })[column]],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// TODO: refresh token?
|
|
|
|
export async function getUserFromNonExpiredLoginToken(
|
|
token: TokenDigest,
|
|
): Promise<User> {
|
|
// TODO: if the token has expired, return a specific error?
|
|
const digest = sha256(base64.decode(token));
|
|
return singleRow(
|
|
await queryObject<User>(
|
|
`select u.* from "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> {
|
|
return singleRow(
|
|
await queryObject<Team>(
|
|
`select * from "team" where "id" = $1`,
|
|
[id],
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getUserTeams(
|
|
{ id }: Partial<User>,
|
|
): Promise<Team[]> {
|
|
return someRows(
|
|
await queryObject<Team>(
|
|
`select t.* from "team" t
|
|
left join "team_user" tu on t.id = tu.team_id
|
|
where tu."user_id" = $1`,
|
|
[id],
|
|
),
|
|
);
|
|
}
|
|
export async function getTeamUsers(
|
|
{ id }: Partial<Team>,
|
|
): Promise<User[]> {
|
|
return someRows(
|
|
await queryObject<User>(
|
|
`select u.* from "user" u
|
|
left join "team_user" tu on u.id = tu.user_id
|
|
where tu."team_id" = $1`,
|
|
[id],
|
|
),
|
|
);
|
|
}
|
|
|
|
// export async function createDisplay(display: Ungenerated<Display>, transaction?: Transaction) {
|
|
// display
|
|
// }
|