feat: initial commit
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
import type { BackendDbInterface } from "../types/DbInterface.js";
|
||||
import {
|
||||
isValidUserPassword,
|
||||
type ClientUser,
|
||||
type CreateUser,
|
||||
type LoginUser,
|
||||
type UpdateUser,
|
||||
type UserFlags,
|
||||
} from "../types/User.js";
|
||||
import { sqliteToClientUser, sqliteToDate, sqliteToUuid } from "./cast.js";
|
||||
import {
|
||||
ConflictAbodeError,
|
||||
InvalidAbodeError,
|
||||
NotAuthorizedAbodeError,
|
||||
NotFoundAbodeError,
|
||||
ReadonlyAbodeError,
|
||||
} from "../types/errors.js";
|
||||
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
||||
import type {
|
||||
CreateResident,
|
||||
Resident,
|
||||
ResidentFlags,
|
||||
updateResident,
|
||||
} from "../types/Resident.js";
|
||||
import { validatePassword } from "../../util/hash.js";
|
||||
import { createApikeyToken, createSessionToken } from "../../util/token.js";
|
||||
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
||||
import { calcUpdates, catSql, joinSql, sql } from "./sql.js";
|
||||
import {
|
||||
selectAbode,
|
||||
selectAbodes,
|
||||
selectClientApikey,
|
||||
selectClientApikeys,
|
||||
selectClientUser,
|
||||
selectClientUsers,
|
||||
selectResident,
|
||||
selectResidents,
|
||||
} from "./query.js";
|
||||
import type {
|
||||
CreateNote,
|
||||
Note,
|
||||
PartialNote,
|
||||
UpdateNote,
|
||||
} from "../types/Note.js";
|
||||
import type { WrappedDb } from "./impl/types.js";
|
||||
|
||||
export class SqliteInterface implements BackendDbInterface {
|
||||
#db: WrappedDb;
|
||||
|
||||
constructor(db: WrappedDb) {
|
||||
this.#db = db;
|
||||
}
|
||||
|
||||
#checkReadonly(): void {
|
||||
if (this.#db.readonly) throw new ReadonlyAbodeError();
|
||||
}
|
||||
|
||||
get _() {
|
||||
return {
|
||||
sql,
|
||||
catSql,
|
||||
joinSql,
|
||||
db: this.#db,
|
||||
};
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this.#db.readonly;
|
||||
}
|
||||
get backend(): true {
|
||||
return true;
|
||||
}
|
||||
get name(): "sqlite" {
|
||||
return "sqlite";
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#db.destroy();
|
||||
}
|
||||
|
||||
async listUsers(): Promise<ClientUser[]> {
|
||||
return selectClientUsers(this.#db);
|
||||
}
|
||||
#getUserById(uid: string): ClientUser {
|
||||
const user = selectClientUser(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||
if (!user) throw new NotFoundAbodeError();
|
||||
return user;
|
||||
}
|
||||
async getUserById(id: string): Promise<ClientUser> {
|
||||
return this.#getUserById(id);
|
||||
}
|
||||
async getUserByEmail(email: string): Promise<ClientUser> {
|
||||
const user = selectClientUser(this.#db, sql`"email" = ${{ text: email }}`);
|
||||
if (!user) throw new NotFoundAbodeError();
|
||||
return user;
|
||||
}
|
||||
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
|
||||
const rawUser = this.#db.get<{
|
||||
uid: Buffer;
|
||||
email: string;
|
||||
name: string;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
password: string;
|
||||
}>(
|
||||
sql`
|
||||
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
|
||||
FROM "users"
|
||||
WHERE "email" = ${{ text: email }}
|
||||
`
|
||||
);
|
||||
if (!rawUser) throw new NotFoundAbodeError();
|
||||
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
|
||||
if (!(await validatePassword(password, rawUser.password)))
|
||||
throw new NotAuthorizedAbodeError();
|
||||
return sqliteToClientUser(rawUser);
|
||||
}
|
||||
async deleteUserById(id: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "users"
|
||||
WHERE "uid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createUser(user: CreateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
|
||||
const uid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
this.#db.run(
|
||||
sql`
|
||||
INSERT INTO "users"("uid", "email", "name", "password", "flags")
|
||||
VALUES(
|
||||
${{ uuid: uid }},
|
||||
${{ text: user.email }},
|
||||
${{ text: user.name }},
|
||||
${{ text: user.password }},${{ jsonb: user.flags }})
|
||||
`
|
||||
);
|
||||
return this.#getUserById(uid);
|
||||
})
|
||||
);
|
||||
}
|
||||
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
const updates = calcUpdates({
|
||||
email: (value: string) => sql`"email" = ${{ text: value }}`,
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
password: (value: string) => {
|
||||
if (!isValidUserPassword(value)) throw new InvalidAbodeError();
|
||||
if (value.startsWith("#")) {
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "apikeys"
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`);
|
||||
}
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`);
|
||||
return sql`"password" = ${{ text: value }}`;
|
||||
},
|
||||
flags: (value: UserFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||
})(user);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
UPDATE "users"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getUserById(user.uid);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async listAbodes(): Promise<Abode[]> {
|
||||
return selectAbodes(this.#db);
|
||||
}
|
||||
#getAbodeById(aid: string): Abode {
|
||||
const abode = selectAbode(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||
if (!abode) throw new NotFoundAbodeError();
|
||||
return abode;
|
||||
}
|
||||
async getAbodeById(id: string): Promise<Abode> {
|
||||
return this.#getAbodeById(id);
|
||||
}
|
||||
async deleteAbodeById(id: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "abodes"
|
||||
WHERE "aid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
const aid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
this.#db.run(
|
||||
sql`
|
||||
INSERT INTO "abodes"("aid", "name", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: aid }},
|
||||
${{ text: abode.name }},
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`
|
||||
);
|
||||
return this.#getAbodeById(aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
})(abode);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
UPDATE "abodes"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
"updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "aid" = ${{ uuid: abode.aid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getAbodeById(abode.aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async listResidents(): Promise<Resident[]> {
|
||||
return selectResidents(this.#db);
|
||||
}
|
||||
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
||||
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||
}
|
||||
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
||||
return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||
}
|
||||
#getResidentById(uid: string, aid: string): Resident {
|
||||
const resident = selectResident(
|
||||
this.#db,
|
||||
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`
|
||||
);
|
||||
if (!resident) throw new NotFoundAbodeError();
|
||||
return resident;
|
||||
}
|
||||
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
||||
return this.#getResidentById(uid, aid);
|
||||
}
|
||||
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "residents"
|
||||
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createResident(
|
||||
resident: CreateResident,
|
||||
ctx: { uid: string }
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
this.#db.run(
|
||||
sql`
|
||||
INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: resident.uid }},
|
||||
${{ uuid: resident.aid }},
|
||||
${{ jsonb: resident.flags }},
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`
|
||||
);
|
||||
return this.#getResidentById(resident.uid, resident.aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
async updateResident(
|
||||
resident: updateResident,
|
||||
ctx: { uid: string }
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||
})(resident);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
UPDATE "residents"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
"updated_by" = ${{ uuid: ctx.uid }}
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE
|
||||
"uid" = ${{ uuid: resident.uid }}
|
||||
AND "aid" = ${{ uuid: resident.aid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getResidentById(resident.uid, resident.aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async listUsersByAbodeId(id: string): Promise<ClientUser[]> {
|
||||
return selectClientUsers(
|
||||
this.#db,
|
||||
sql`
|
||||
JOIN "residents" r ON u."uid" = r."uid"
|
||||
WHERE r."aid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
}
|
||||
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
||||
return selectAbodes(
|
||||
this.#db,
|
||||
sql`
|
||||
JOIN "residents" r ON a."aid" = r."aid"
|
||||
WHERE r."uid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
|
||||
const session = this.#db.get<{ uid: Buffer; expires_at: string }>(sql`
|
||||
SELECT "uid", "expires_at"
|
||||
FROM "sessions"
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
if (!session) throw new NotFoundAbodeError();
|
||||
if (new Date(sqliteToDate(session.expires_at)).getTime() < Date.now()) {
|
||||
if (!this.readonly) {
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "expires_at" < datetime('now', 'localtime', 'subsec')
|
||||
`);
|
||||
}
|
||||
throw new NotFoundAbodeError();
|
||||
}
|
||||
if (!this.readonly) {
|
||||
this.#db.run(sql`
|
||||
UPDATE "sessions"
|
||||
SET "expires_at" = datetime('now', 'localtime', 'subsec', '+7 days')
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
}
|
||||
return this.#getUserById(sqliteToUuid(session.uid));
|
||||
}
|
||||
async createSession(uid: string): Promise<`as_${string}`> {
|
||||
this.#checkReadonly();
|
||||
const token = createSessionToken();
|
||||
this.#db.run(sql`
|
||||
INSERT INTO "sessions"("uid", "token")
|
||||
VALUES(${{ uuid: uid }}, ${{ text: token }})
|
||||
`);
|
||||
return token;
|
||||
}
|
||||
async deleteSessionsByUser(uid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "uid" = ${{ uuid: uid }}
|
||||
`);
|
||||
}
|
||||
|
||||
#getApikeyByToken(token: `at_${string}`): ClientApikey {
|
||||
const apikey = selectClientApikey(
|
||||
this.#db,
|
||||
sql`"token" = ${{ text: token }}`
|
||||
);
|
||||
if (!apikey) throw new NotFoundAbodeError();
|
||||
return apikey;
|
||||
}
|
||||
async getUserByApikey(
|
||||
token: `at_${string}`
|
||||
): Promise<[ClientUser, ClientApikey]> {
|
||||
const apikey = this.#getApikeyByToken(token);
|
||||
if (
|
||||
apikey.expires_at &&
|
||||
new Date(apikey.expires_at).getTime() < Date.now()
|
||||
) {
|
||||
throw new NotAuthorizedAbodeError();
|
||||
}
|
||||
return [this.#getUserById(apikey.uid), apikey];
|
||||
}
|
||||
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
||||
return selectClientApikeys(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||
}
|
||||
async getApikeyById(kid: string): Promise<ClientApikey> {
|
||||
const apikey = selectClientApikey(this.#db, sql`"kid" = ${{ uuid: kid }}`);
|
||||
if (!apikey) throw new NotFoundAbodeError();
|
||||
return apikey;
|
||||
}
|
||||
async createApikey(
|
||||
apikey: CreateApikey
|
||||
): Promise<[ClientApikey, `at_${string}`]> {
|
||||
this.#checkReadonly();
|
||||
const token = createApikeyToken();
|
||||
const kid = crypto.randomUUID();
|
||||
let expires = apikey.expires_at;
|
||||
if (expires === undefined)
|
||||
expires = new Date(
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365
|
||||
).toISOString();
|
||||
if (expires && new Date(expires).getTime() < Date.now())
|
||||
throw new InvalidAbodeError();
|
||||
|
||||
this.#db.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: apikey.uid }},
|
||||
${{ uuid: kid }},
|
||||
${{ text: token }},
|
||||
${{ text: apikey.name }},
|
||||
${{ jsonb: apikey.permissions }},
|
||||
${expires ? { date: expires } : { null: true }}
|
||||
)
|
||||
`);
|
||||
return [this.#getApikeyByToken(token), token];
|
||||
}
|
||||
async deleteApikeyById(kid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(sql`
|
||||
DELETE FROM "apikeys"
|
||||
WHERE "kid" = ${{ uuid: kid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user