feat: add dark mode UI and SQLite as alternate database backend
Release Please / release-please (push) Has been cancelled
Docker Build and Push (Development) / build-and-push-dev (push) Has been cancelled

Dark mode: CSS custom properties + .dark class toggled via Redux/localStorage,
with a theme toggle in the Header and Settings page. Foundation didn't exist
yet despite prior assumption, so it was built from scratch.

SQLite: new DB_ENGINE=mongo|sqlite env var selects the metadata backend at
runtime (Mongo stays default, no behavior change unless opted in). File/thumbnail
bytes continue to use the existing fs/S3 storage path. Implemented via a shared
DB interface (dbTypes.ts) with parallel mongoDB/ and sqliteDB/ implementations,
selected through dbFactory.ts, with JWT/encryption logic extracted into
userCrypto.ts so both engines share it.
This commit is contained in:
2026-07-05 04:18:33 -07:00
parent bae5dbbd53
commit ecc340e7b1
74 changed files with 2233 additions and 487 deletions
+452
View File
@@ -0,0 +1,452 @@
import db from "../connections/sqlite";
import { ObjectId } from "mongodb";
import { IFileDB } from "../dbTypes";
import { FileListQueryType } from "../../types/file-types";
const rowToFile = (row: any) => {
if (!row) return null;
return {
_id: row.id,
length: row.length,
chunkSize: row.chunkSize,
uploadDate: row.uploadDate,
filename: row.filename,
metadata: {
owner: row.owner,
parent: row.parent,
parentList: row.parentList,
hasThumbnail: !!row.hasThumbnail,
isVideo: !!row.isVideo,
thumbnailID: row.thumbnailID || undefined,
size: row.size,
IV: row.iv,
linkType: row.linkType || undefined,
link: row.link || undefined,
filePath: row.filePath || undefined,
s3ID: row.s3ID || undefined,
personalFile: row.personalFile ? true : undefined,
trashed: row.trashed ? true : null,
processingFile: row.processingFile ? true : null,
},
};
};
const sortColumn = (sortBy: string): { column: string; direction: string } => {
if (sortBy === "date_desc") return { column: "uploadDate", direction: "DESC" };
if (sortBy === "date_asc") return { column: "uploadDate", direction: "ASC" };
if (sortBy === "alp_desc")
return { column: "filename COLLATE NOCASE", direction: "DESC" };
return { column: "filename COLLATE NOCASE", direction: "ASC" };
};
class FileDB implements IFileDB {
constructor() {}
// READ
getPublicFile = async (fileID: string) => {
const row = db.prepare("SELECT * FROM files WHERE id = ?").get(fileID);
return rowToFile(row);
};
getPublicInfo = async (fileID: string, tempToken: string) => {
const row = db
.prepare("SELECT * FROM files WHERE id = ? AND link = ?")
.get(fileID, tempToken);
return rowToFile(row);
};
getFileInfo = async (fileID: string, userID: string) => {
const row = db
.prepare("SELECT * FROM files WHERE id = ? AND owner = ?")
.get(fileID, userID);
return rowToFile(row);
};
getQuickList = async (userID: string, limit: number) => {
const rows = db
.prepare(
`SELECT * FROM files WHERE owner = ? AND trashed = 0 AND processingFile = 0
ORDER BY uploadDate DESC LIMIT ?`
)
.all(userID, limit);
return rows.map(rowToFile);
};
getList = async (
queryData: FileListQueryType,
sortBy: string,
limit: number
) => {
const {
userID,
search,
parent,
startAtDate,
startAtName,
trashMode,
mediaMode,
mediaFilter,
} = queryData;
const conditions: string[] = ["owner = ?"];
const params: any[] = [userID];
if (search && search !== "") {
conditions.push("filename LIKE ? COLLATE NOCASE");
params.push(`%${search}%`);
} else if (!mediaMode) {
conditions.push("parent = ?");
params.push(parent);
}
if (sortBy === "date_desc" && startAtDate) {
conditions.push("uploadDate < ?");
params.push(new Date(startAtDate).toISOString());
} else if (sortBy === "date_asc" && startAtDate) {
conditions.push("uploadDate > ?");
params.push(new Date(startAtDate).toISOString());
} else if (sortBy === "alp_desc" && startAtName) {
conditions.push("filename < ? COLLATE NOCASE");
params.push(startAtName);
} else if (sortBy === "alp_asc" && startAtName) {
conditions.push("filename > ? COLLATE NOCASE");
params.push(startAtName);
}
conditions.push("trashed = ?");
params.push(trashMode ? 1 : 0);
if (mediaMode) {
conditions.push("hasThumbnail = 1");
if (mediaFilter === "photos") conditions.push("isVideo = 0");
else if (mediaFilter === "videos") conditions.push("isVideo = 1");
}
conditions.push("processingFile = 0");
const { column, direction } = sortColumn(sortBy);
const rows = db
.prepare(
`SELECT * FROM files WHERE ${conditions.join(
" AND "
)} ORDER BY ${column} ${direction} LIMIT ?`
)
.all(...params, limit);
return rows.map(rowToFile);
};
getFileSearchList = async (
userID: string,
searchQuery: RegExp | string,
trashMode: boolean,
mediaMode: boolean
) => {
const search =
searchQuery instanceof RegExp ? searchQuery.source : searchQuery;
const conditions = [
"owner = ?",
"filename LIKE ? COLLATE NOCASE",
"trashed = ?",
"processingFile = 0",
];
const params: any[] = [userID, `%${search}%`, trashMode ? 1 : 0];
if (mediaMode) conditions.push("hasThumbnail = 1");
const rows = db
.prepare(
`SELECT * FROM files WHERE ${conditions.join(" AND ")} LIMIT 10`
)
.all(...params);
return rows.map(rowToFile);
};
getFileListByIncludedParent = async (
userID: string,
parentListString: string
) => {
const rows = db
.prepare(
"SELECT * FROM files WHERE owner = ? AND parentList LIKE ?"
)
.all(userID, `%${parentListString}%`);
return rows.map(rowToFile);
};
getFileListByOwner = async (userID: string) => {
const rows = db.prepare("SELECT * FROM files WHERE owner = ?").all(userID);
return rows.map(rowToFile);
};
getFileListByParent = async (userID: string, parent: string) => {
const rows = db
.prepare("SELECT * FROM files WHERE owner = ? AND parent = ?")
.all(userID, parent);
return rows.map(rowToFile);
};
// UPDATE
updateFileUploadedFile = async (
fileID: string,
userID: string,
parent: string,
parentList: string
) => {
db.prepare(
`UPDATE files SET parent = ?, parentList = ?, processingFile = 0
WHERE id = ? AND owner = ?`
).run(parent, parentList, fileID, userID);
return this.getFileInfo(fileID, userID);
};
updateFolderUploadedFile = async (
fileID: string,
userID: string,
parent: string,
parentList: string
) => {
return this.updateFileUploadedFile(fileID, userID, parent, parentList);
};
setThumbnail = async (fileID: string, thumbnailID: string) => {
const result = db
.prepare(
`UPDATE files SET hasThumbnail = 1, thumbnailID = ?
WHERE id = ? AND hasThumbnail = 0`
)
.run(thumbnailID, fileID);
if (result.changes === 0) return null;
const row = db.prepare("SELECT * FROM files WHERE id = ?").get(fileID);
return rowToFile(row);
};
setVideoThumbnail = async (
fileID: string,
userID: string,
thumbnailID: string
) => {
const result = db
.prepare(
`UPDATE files SET hasThumbnail = 1, thumbnailID = ?, isVideo = 1
WHERE id = ? AND owner = ?`
)
.run(thumbnailID, fileID, userID);
if (result.changes === 0) return null;
return this.getFileInfo(fileID, userID);
};
removeOneTimePublicLink = async (fileID: string) => {
db.prepare(
"UPDATE files SET linkType = NULL, link = NULL WHERE id = ?"
).run(fileID);
const row = db.prepare("SELECT * FROM files WHERE id = ?").get(fileID);
return rowToFile(row);
};
removeLink = async (fileID: string, userID: string) => {
db.prepare(
"UPDATE files SET linkType = NULL, link = NULL WHERE id = ? AND owner = ?"
).run(fileID, userID);
return this.getFileInfo(fileID, userID);
};
makePublic = async (fileID: string, userID: string, token: string) => {
const existing: any = db
.prepare("SELECT * FROM files WHERE id = ? AND owner = ?")
.get(fileID, userID);
if (!existing) return null;
db.prepare(
"UPDATE files SET linkType = 'public', link = ? WHERE id = ? AND owner = ?"
).run(token, fileID, userID);
return this.getFileInfo(fileID, userID);
};
makeOneTimePublic = async (fileID: string, userID: string, token: string) => {
const existing: any = db
.prepare("SELECT * FROM files WHERE id = ? AND owner = ?")
.get(fileID, userID);
if (!existing) return null;
db.prepare(
"UPDATE files SET linkType = 'one', link = ? WHERE id = ? AND owner = ?"
).run(token, fileID, userID);
return this.getFileInfo(fileID, userID);
};
trashFile = async (
fileID: string,
parent: string,
parentList: string,
userID: string
) => {
const existing: any = db
.prepare("SELECT * FROM files WHERE id = ? AND owner = ?")
.get(fileID, userID);
if (!existing) return null;
db.prepare(
"UPDATE files SET trashed = 1, parent = ?, parentList = ? WHERE id = ? AND owner = ?"
).run(parent, parentList, fileID, userID);
return this.getFileInfo(fileID, userID);
};
restoreFile = async (fileID: string, userID: string) => {
const existing: any = db
.prepare("SELECT * FROM files WHERE id = ? AND owner = ?")
.get(fileID, userID);
if (!existing) return null;
db.prepare("UPDATE files SET trashed = 0 WHERE id = ? AND owner = ?").run(
fileID,
userID
);
return this.getFileInfo(fileID, userID);
};
removeTempToken = async (user: any, tempToken: string) => {
user.tempTokens = user.tempTokens.filter(
(filterToken: any) => filterToken.token !== tempToken
);
return user;
};
trashFilesByParent = async (parentList: string, userID: string) => {
const result = db
.prepare(
"UPDATE files SET trashed = 1 WHERE owner = ? AND parentList LIKE ?"
)
.run(userID, `%${parentList}%`);
return result;
};
restoreFilesByParent = async (parentList: string, userID: string) => {
const result = db
.prepare(
"UPDATE files SET trashed = 0 WHERE owner = ? AND parentList LIKE ?"
)
.run(userID, `%${parentList}%`);
return result;
};
renameFile = async (fileID: string, userID: string, title: string) => {
const existing: any = db
.prepare("SELECT * FROM files WHERE id = ? AND owner = ?")
.get(fileID, userID);
if (!existing) return null;
db.prepare("UPDATE files SET filename = ? WHERE id = ? AND owner = ?").run(
title,
fileID,
userID
);
return this.getFileInfo(fileID, userID);
};
moveFile = async (
fileID: string,
userID: string,
parent: string,
parentList: string
) => {
const existing: any = db
.prepare("SELECT * FROM files WHERE id = ? AND owner = ?")
.get(fileID, userID);
if (!existing) return null;
db.prepare(
"UPDATE files SET parent = ?, parentList = ? WHERE id = ? AND owner = ?"
).run(parent, parentList, fileID, userID);
return this.getFileInfo(fileID, userID);
};
moveMultipleFiles = async (
userID: string,
currentParent: string,
newParent: string,
newParentList: string
) => {
db.prepare(
"UPDATE files SET parent = ?, parentList = ? WHERE owner = ? AND parent = ?"
).run(newParent, newParentList, userID, currentParent);
};
// DELETE
deleteFile = async (fileID: string, userID: string) => {
const result = db
.prepare("DELETE FROM files WHERE id = ? AND owner = ?")
.run(fileID, userID);
return result;
};
// CREATE
createFile = async (data: {
filename: string;
uploadDate: string;
length: number;
metadata: any;
}) => {
const id = new ObjectId().toString();
const m = data.metadata || {};
db.prepare(
`INSERT INTO files (
id, filename, length, chunkSize, uploadDate, owner, parent, parentList,
hasThumbnail, isVideo, thumbnailID, size, iv, linkType, link, filePath,
s3ID, personalFile, trashed, processingFile
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
data.filename,
data.length,
null,
data.uploadDate,
m.owner,
m.parent,
m.parentList,
m.hasThumbnail ? 1 : 0,
m.isVideo ? 1 : 0,
m.thumbnailID || null,
m.size,
m.IV,
m.linkType || null,
m.link || null,
m.filePath || null,
m.s3ID || null,
m.personalFile ? 1 : 0,
m.trashed ? 1 : 0,
m.processingFile ? 1 : 0
);
const row = db.prepare("SELECT * FROM files WHERE id = ?").get(id);
return rowToFile(row);
};
}
export default FileDB;
+307
View File
@@ -0,0 +1,307 @@
import db from "../connections/sqlite";
import { ObjectId } from "mongodb";
import { IFolderDB } from "../dbTypes";
import { FolderListQueryType } from "../../types/folder-types";
const encodeParentList = (parentList: (string | any)[]): string =>
parentList.map((id) => id.toString()).join(",");
const decodeParentList = (parentList: string): string[] =>
parentList ? parentList.split(",").filter((p) => p !== "") : [];
const rowToFolder = (row: any) => {
if (!row) return null;
return {
_id: row.id,
name: row.name,
parent: row.parent,
owner: row.owner,
parentList: decodeParentList(row.parentList),
personalFolder: row.personalFolder ? true : undefined,
trashed: row.trashed ? true : null,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
};
// Wraps the stored comma-joined parentList with delimiters at query time so
// membership checks can't match a partial id (mirrors Mongo's array $in/$all/$nin
// semantics, which match whole elements, not substrings).
const containsClause = () => "(',' || parentList || ',') LIKE ?";
const containsParam = (id: string) => `%,${id},%`;
const sortColumn = (sortBy: string): { column: string; direction: string } => {
if (sortBy === "alp_asc") return { column: "name COLLATE NOCASE", direction: "ASC" };
if (sortBy === "alp_desc") return { column: "name COLLATE NOCASE", direction: "DESC" };
if (sortBy === "date_asc") return { column: "createdAt", direction: "ASC" };
return { column: "createdAt", direction: "DESC" };
};
class FolderDB implements IFolderDB {
constructor() {}
// READ
getFolderSearchList = async (
userID: string,
searchQuery: RegExp | string,
trashMode: boolean
) => {
const search =
searchQuery instanceof RegExp ? searchQuery.source : searchQuery;
const rows = db
.prepare(
`SELECT * FROM folders WHERE owner = ? AND name LIKE ? COLLATE NOCASE AND trashed = ?
LIMIT 10`
)
.all(userID, `%${search}%`, trashMode ? 1 : 0);
return rows.map(rowToFolder);
};
getFolderInfo = async (folderID: string, userID: string) => {
const row = db
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
.get(folderID, userID);
return rowToFolder(row);
};
getFolderList = async (queryData: FolderListQueryType, sortBy: string) => {
const { userID, search, parent, trashMode } = queryData;
const conditions = ["owner = ?"];
const params: any[] = [userID];
if (search && search !== "") {
conditions.push("name LIKE ? COLLATE NOCASE");
params.push(`%${search}%`);
} else {
conditions.push("parent = ?");
params.push(parent);
}
conditions.push("trashed = ?");
params.push(trashMode ? 1 : 0);
const { column, direction } = sortColumn(sortBy);
const rows = db
.prepare(
`SELECT * FROM folders WHERE ${conditions.join(
" AND "
)} ORDER BY ${column} ${direction}`
)
.all(...params);
return rows.map(rowToFolder);
};
getMoveFolderList = async (
userID: string,
parent = "/",
search?: string,
folderIDs?: string[]
) => {
const conditions = ["owner = ?", "trashed = 0"];
const params: any[] = [userID];
if (!search || search === "") {
conditions.push("parent = ?");
params.push(parent);
} else {
conditions.push("name LIKE ? COLLATE NOCASE");
params.push(`%${search}%`);
}
const rows = db
.prepare(
`SELECT * FROM folders WHERE ${conditions.join(
" AND "
)} ORDER BY createdAt DESC`
)
.all(...params) as any[];
const filtered =
folderIDs && folderIDs.length > 0
? rows.filter((row) => {
if (folderIDs.includes(row.id)) return false;
const list = decodeParentList(row.parentList);
return !folderIDs.some((id) => list.includes(id));
})
: rows;
return filtered.map(rowToFolder);
};
getFolderListByIncludedParent = async (userID: string, parent: string) => {
const rows = db
.prepare(
`SELECT * FROM folders WHERE owner = ? AND ${containsClause()} AND trashed = 0`
)
.all(userID, containsParam(parent));
return rows.map(rowToFolder);
};
findAllFoldersByParent = async (parentID: string, userID: string) => {
const rows = db
.prepare(`SELECT * FROM folders WHERE owner = ? AND ${containsClause()}`)
.all(userID, containsParam(parentID));
return rows.map(rowToFolder);
};
// UPDATE
moveFolder = async (
folderID: string,
userID: string,
parent: string,
parentList: string[]
) => {
const existing: any = db
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
.get(folderID, userID);
if (!existing) return null;
db.prepare(
"UPDATE folders SET parent = ?, parentList = ?, updatedAt = ? WHERE id = ? AND owner = ?"
).run(
parent,
encodeParentList(parentList),
new Date().toISOString(),
folderID,
userID
);
return this.getFolderInfo(folderID, userID);
};
renameFolder = async (folderID: string, userID: string, title: string) => {
const existing: any = db
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
.get(folderID, userID);
if (!existing) return null;
db.prepare(
"UPDATE folders SET name = ?, updatedAt = ? WHERE id = ? AND owner = ?"
).run(title, new Date().toISOString(), folderID, userID);
return this.getFolderInfo(folderID, userID);
};
trashFoldersByParent = async (parentList: string[], userID: string) => {
const clauses = parentList.map(() => containsClause()).join(" AND ");
const params = parentList.map((id) => containsParam(id));
const result = db
.prepare(
`UPDATE folders SET trashed = 1, updatedAt = ? WHERE owner = ? AND ${clauses}`
)
.run(new Date().toISOString(), userID, ...params);
return result;
};
restoreFolder = async (folderID: string, userID: string) => {
const existing: any = db
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
.get(folderID, userID);
if (!existing) return null;
db.prepare(
"UPDATE folders SET trashed = 0, updatedAt = ? WHERE id = ? AND owner = ?"
).run(new Date().toISOString(), folderID, userID);
return this.getFolderInfo(folderID, userID);
};
restoreFoldersByParent = async (parentList: string[], userID: string) => {
const clauses = parentList.map(() => containsClause()).join(" AND ");
const params = parentList.map((id) => containsParam(id));
const result = db
.prepare(
`UPDATE folders SET trashed = 0, updatedAt = ? WHERE owner = ? AND ${clauses}`
)
.run(new Date().toISOString(), userID, ...params);
return result;
};
trashFolder = async (folderID: string, userID: string) => {
const existing: any = db
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
.get(folderID, userID);
if (!existing) return null;
db.prepare(
"UPDATE folders SET trashed = 1, updatedAt = ? WHERE id = ? AND owner = ?"
).run(new Date().toISOString(), folderID, userID);
return this.getFolderInfo(folderID, userID);
};
// CREATE
createFolder = async (folderData: {
name: string;
parent: string;
parentList: string[];
owner: string;
}) => {
const id = new ObjectId().toString();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO folders (id, name, parent, owner, parentList, personalFolder, trashed, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
folderData.name,
folderData.parent,
folderData.owner,
encodeParentList(folderData.parentList),
0,
0,
now,
now
);
const row = db.prepare("SELECT * FROM folders WHERE id = ?").get(id);
return rowToFolder(row);
};
// DELETE
deleteFolder = async (folderID: string, userID: string) => {
const result = db
.prepare("DELETE FROM folders WHERE id = ? AND owner = ?")
.run(folderID, userID);
return result;
};
deleteFoldersByParentList = async (
parentList: string[],
userID: string
) => {
const clauses = parentList.map(() => containsClause()).join(" AND ");
const params = parentList.map((id) => containsParam(id.toString()));
const result = db
.prepare(`DELETE FROM folders WHERE owner = ? AND ${clauses}`)
.run(userID, ...params);
return result;
};
deleteFoldersByOwner = async (userID: string) => {
const result = db
.prepare("DELETE FROM folders WHERE owner = ?")
.run(userID);
return result;
};
}
export default FolderDB;
+76
View File
@@ -0,0 +1,76 @@
import db from "../connections/sqlite";
import { ObjectId } from "mongodb";
import { IThumbnailDB } from "../dbTypes";
const rowToThumbnail = (row: any) => {
if (!row) return null;
return {
_id: row.id,
name: row.name,
owner: row.owner,
data: row.data || undefined,
path: row.path || undefined,
IV: row.iv || undefined,
s3ID: row.s3ID || undefined,
personalFile: row.personalFile ? true : undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
};
class ThumbnailDB implements IThumbnailDB {
constructor() {}
// READ
getThumbnailInfo = async (userID: string, thumbnailID: string) => {
const row = db
.prepare("SELECT * FROM thumbnails WHERE id = ? AND owner = ?")
.get(thumbnailID, userID);
return rowToThumbnail(row);
};
// DELETE
removeThumbnail = async (userID: string, thumbnailID: any) => {
const result = db
.prepare("DELETE FROM thumbnails WHERE id = ? AND owner = ?")
.run(thumbnailID.toString(), userID);
return result;
};
// CREATE
createThumbnail = async (data: {
name: string;
owner: string;
IV: Buffer;
path?: string;
s3ID?: string;
}) => {
const id = new ObjectId().toString();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO thumbnails (id, name, owner, data, path, iv, s3ID, personalFile, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
data.name,
data.owner,
null,
data.path || null,
data.IV,
data.s3ID || null,
0,
now,
now
);
const row = db.prepare("SELECT * FROM thumbnails WHERE id = ?").get(id);
return rowToThumbnail(row);
};
}
export default ThumbnailDB;
+267
View File
@@ -0,0 +1,267 @@
import crypto from "crypto";
import db from "../connections/sqlite";
import { ObjectId } from "mongodb";
import { IUserDB } from "../dbTypes";
import env from "../../enviroment/env";
import NotAuthorizedError from "../../utils/NotAuthorizedError";
import {
encryptToken,
decryptToken,
getEncryptionKey,
generateEncryptionKeyMaterial,
hashPassword,
comparePassword,
signAuthTokens,
signStreamVideoToken,
signTempAuthToken,
signEmailVerifyToken,
signPasswordResetToken,
} from "../../utils/userCrypto";
type TokenRecord = { token: string; uuid?: string; time?: number };
class SqliteUserDoc {
_id: string;
email: string;
password: string;
tokens: TokenRecord[];
tempTokens: TokenRecord[];
privateKey?: string;
publicKey?: string;
emailVerified?: boolean;
emailToken?: string;
passwordResetToken?: string;
passwordLastModified?: number;
createdAt: Date;
updatedAt: Date;
private originalPasswordHash: string;
constructor(row: any) {
this._id = row.id;
this.email = row.email;
this.password = row.password;
this.originalPasswordHash = row.password;
this.privateKey = row.privateKey || undefined;
this.publicKey = row.publicKey || undefined;
this.emailVerified = !!row.emailVerified;
this.emailToken = row.emailToken || undefined;
this.passwordResetToken = row.passwordResetToken || undefined;
this.passwordLastModified = row.passwordLastModified || undefined;
this.createdAt = new Date(row.createdAt);
this.updatedAt = new Date(row.updatedAt);
const tokenRows = db
.prepare("SELECT * FROM user_tokens WHERE user_id = ?")
.all(this._id) as any[];
this.tokens = tokenRows
.filter((t) => !t.is_temp)
.map((t) => ({ token: t.token, uuid: t.uuid, time: t.time }));
this.tempTokens = tokenRows
.filter((t) => t.is_temp)
.map((t) => ({ token: t.token, uuid: t.uuid, time: t.time }));
}
getEncryptionKey = () => getEncryptionKey(this as any);
encryptToken = (token: string, key: any, iv: any) =>
encryptToken(token, key, iv);
decryptToken = (encryptedToken: any, key: string, iv: any) =>
decryptToken(encryptedToken, key, iv);
private persistTokens = () => {
db.prepare("DELETE FROM user_tokens WHERE user_id = ?").run(this._id);
const insert = db.prepare(
"INSERT INTO user_tokens (user_id, token, is_temp, uuid, time) VALUES (?, ?, ?, ?, ?)"
);
for (const t of this.tokens) {
insert.run(this._id, t.token, 0, t.uuid ?? null, t.time ?? null);
}
for (const t of this.tempTokens) {
insert.run(this._id, t.token, 1, t.uuid ?? null, t.time ?? null);
}
};
generateAuthToken = async (uuid: string | undefined) => {
const { accessToken, refreshToken, encryptedRefreshToken, time } =
signAuthTokens(this as any);
this.tokens.push({
token: encryptedRefreshToken,
uuid: uuid || "unknown",
time,
});
this.persistTokens();
return { accessToken, refreshToken };
};
generateAuthTokenStreamVideo = async (uuid: string | undefined) => {
const { token, encryptedToken: enc, time } = signStreamVideoToken(
this as any
);
this.tempTokens.push({ token: enc, uuid: uuid || "unknown", time });
this.persistTokens();
return token;
};
generateTempAuthToken = async () => {
const { token, encryptedToken: enc } = signTempAuthToken(this as any);
this.tempTokens.push({ token: enc });
this.persistTokens();
return token;
};
generateEncryptionKeys = async () => {
const randomKey = crypto.randomBytes(32);
const { privateKey, publicKey } = generateEncryptionKeyMaterial(
this.password,
randomKey
);
this.privateKey = privateKey;
this.publicKey = publicKey;
await this.save();
};
changeEncryptionKey = async (randomKey: Buffer) => {
const { privateKey, publicKey } = generateEncryptionKeyMaterial(
this.password,
randomKey
);
this.privateKey = privateKey;
this.publicKey = publicKey;
await this.save();
};
generateEmailVerifyToken = async () => {
const { token, encryptedToken: enc } = signEmailVerifyToken(this as any);
this.emailToken = enc;
await this.save();
return token;
};
generatePasswordResetToken = async () => {
const { token, encryptedToken: enc } = signPasswordResetToken(
this as any
);
this.passwordResetToken = enc;
await this.save();
return token;
};
save = async () => {
if (this.password !== this.originalPasswordHash) {
this.password = await hashPassword(this.password);
this.originalPasswordHash = this.password;
}
db.prepare(
`UPDATE users SET email = ?, password = ?, privateKey = ?, publicKey = ?,
emailVerified = ?, emailToken = ?, passwordResetToken = ?, passwordLastModified = ?,
updatedAt = ? WHERE id = ?`
).run(
this.email,
this.password,
this.privateKey ?? null,
this.publicKey ?? null,
this.emailVerified ? 1 : 0,
this.emailToken ?? null,
this.passwordResetToken ?? null,
this.passwordLastModified ?? null,
new Date().toISOString(),
this._id
);
this.persistTokens();
};
toJSON = () => {
const obj: any = {
_id: this._id,
email: this.email,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
if (env.emailVerification === "true") {
obj.emailVerified = this.emailVerified || false;
}
return obj;
};
}
const rowToUser = (row: any) => (row ? new SqliteUserDoc(row) : null);
class UserDB implements IUserDB {
constructor() {}
getUserInfo = async (userID: string) => {
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(userID);
return rowToUser(row);
};
findByEmail = async (email: string) => {
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
return rowToUser(row);
};
findByCreds = async (email: string, password: string) => {
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
if (!row) throw new NotAuthorizedError("User not found");
const user = rowToUser(row)!;
const isMatch = await comparePassword(password, user.password);
if (!isMatch) throw new NotAuthorizedError("Incorrect password");
return user;
};
createUser = async (email: string, password: string) => {
const id = new ObjectId().toString();
const now = new Date().toISOString();
const hashedPassword = await hashPassword(password);
db.prepare(
`INSERT INTO users (id, email, password, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`
).run(id, email, hashedPassword, now, now);
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
return rowToUser(row);
};
removeOldTokens = async (
userID: string,
uuid: string | undefined,
minusTime: number,
isTemp: boolean
) => {
uuid = uuid ? uuid : "unknown";
if (uuid === "unknown") return;
db.prepare(
"DELETE FROM user_tokens WHERE user_id = ? AND is_temp = ? AND uuid = ? AND time < ?"
).run(userID, isTemp ? 1 : 0, uuid, minusTime);
};
removeTempTokenByValue = async (userID: string, token: string) => {
db.prepare(
"DELETE FROM user_tokens WHERE user_id = ? AND is_temp = 1 AND token = ?"
).run(userID, token);
};
}
export default UserDB;