Spaces:
Running
Running
/** | |
* Friends chat-plugin database handler. | |
* @author mia-pi-git | |
*/ | |
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
// @ts-ignore in case it isn't installed | |
import type * as Database from 'better-sqlite3'; | |
import { Utils, FS, ProcessManager, Repl } from '../lib'; | |
import { Config } from './config-loader'; | |
import * as path from 'path'; | |
/** Max friends per user */ | |
export const MAX_FRIENDS = 100; | |
/** Max friend requests. */ | |
export const MAX_REQUESTS = 6; | |
export const DEFAULT_FILE = FS('databases/friends.db').path; | |
const REQUEST_EXPIRY_TIME = 30 * 24 * 60 * 60 * 1000; | |
const PM_TIMEOUT = 30 * 60 * 1000; | |
export interface DatabaseRequest { | |
statement: string; | |
type: 'all' | 'get' | 'run' | 'transaction'; | |
data: AnyObject | any[]; | |
} | |
export interface DatabaseResult { | |
/** Specify this to return an error message to the user */ | |
error?: string; | |
result?: any; | |
} | |
export interface Friend { | |
/** Always the same as Friend#friend. Use whichever you want. */ | |
userid: ID; | |
/** Always the same as Friend#userid. Use whichever you want. */ | |
friend: ID; | |
send_login_data: number; | |
last_login: number; | |
public_list: number; | |
allowing_login: number; | |
} | |
/** Like Chat.ErrorMessage, but made for the subprocess so we can throw errors to the user not using errorMessage | |
* because errorMessage crashes when imported (plus we have to spawn dex, etc, all unnecessary - this is easier) | |
*/ | |
export class FailureMessage extends Error { | |
constructor(message: string) { | |
super(message); | |
this.name = 'FailureMessage'; | |
Error.captureStackTrace(this, FailureMessage); | |
} | |
} | |
export function sendPM(message: string, to: string, from = '~') { | |
const senderID = toID(from); | |
const receiverID = toID(to); | |
const sendingUser = Users.get(senderID); | |
const receivingUser = Users.get(receiverID); | |
const fromIdentity = sendingUser ? sendingUser.getIdentity() : ` ${senderID}`; | |
const toIdentity = receivingUser ? receivingUser.getIdentity() : ` ${receiverID}`; | |
if (from === '~') { | |
return receivingUser?.send(`|pm|~|${toIdentity}|${message}`); | |
} | |
receivingUser?.send(`|pm|${fromIdentity}|${toIdentity}|${message}`); | |
} | |
function canPM(sender: User, receiver: User | null) { | |
if (!receiver?.settings.blockPMs) return true; | |
if (receiver.settings.blockPMs === true) return sender.can('lock'); | |
if (receiver.settings.blockPMs === 'friends') return false; | |
return Users.globalAuth.atLeast(sender, receiver.settings.blockPMs); | |
} | |
export class FriendsDatabase { | |
file: string; | |
constructor(file: string = DEFAULT_FILE) { | |
this.file = file === ':memory:' ? file : path.resolve(file); | |
} | |
async updateUserCache(user: User) { | |
user.friends = new Set(); // we clear to account for users who may have been deleted | |
const friends = await this.getFriends(user.id); | |
for (const friend of friends) { | |
user.friends.add(friend.userid); | |
} | |
return user.friends; | |
} | |
static setupDatabase(fileName?: string) { | |
const file = fileName || process.env.filename || DEFAULT_FILE; | |
const exists = FS(file).existsSync() || file === ':memory:'; | |
const database: Database.Database = new (require('better-sqlite3'))(file); | |
if (!exists) { | |
database.exec(FS('databases/schemas/friends.sql').readSync()); | |
} else { | |
let val; | |
try { | |
val = (database | |
.prepare(`SELECT val FROM database_settings WHERE name = 'version'`) | |
.get() as AnyObject).val; | |
} catch {} | |
const actualVersion = FS(`databases/migrations/friends`).readdirIfExistsSync().length; | |
if (val === undefined) { | |
// hasn't been set up before, write new version. | |
database.exec(FS('databases/schemas/friends.sql').readSync()); | |
} | |
if (typeof val === 'number' && val !== actualVersion) { | |
throw new Error(`Friends DB is out of date, please migrate to latest version.`); | |
} | |
} | |
database.exec(FS(`databases/schemas/friends-startup.sql`).readSync()); | |
for (const k in FUNCTIONS) { | |
database.function(k, FUNCTIONS[k]); | |
} | |
for (const k in ACTIONS) { | |
try { | |
statements[k] = database.prepare(ACTIONS[k as keyof typeof ACTIONS]); | |
} catch (e: any) { | |
throw new Error(`Friends DB statement crashed: ${ACTIONS[k as keyof typeof ACTIONS]} (${e.message})`); | |
} | |
} | |
for (const k in TRANSACTIONS) { | |
transactions[k] = database.transaction(TRANSACTIONS[k]); | |
} | |
statements.expire.run(); | |
return { database, statements }; | |
} | |
async getFriends(userid: ID): Promise<Friend[]> { | |
return (await this.all('get', [userid, MAX_FRIENDS])) || []; | |
} | |
async getRequests(user: User) { | |
const sent = new Set<string>(); | |
const received = new Set<string>(); | |
if (user.settings.blockFriendRequests) { | |
// delete any pending requests that may have been sent to them while offline | |
// we used to return but we will not since you can send requests while blocking | |
await this.run('deleteReceivedRequests', [user.id]); | |
} | |
const sentResults = await this.all('getSent', [user.id]); | |
if (sentResults === null) return { sent, received }; | |
for (const request of sentResults) { | |
sent.add(request.receiver); | |
} | |
const receivedResults = await this.all('getReceived', [user.id]) || []; | |
if (!receivedResults) { | |
return { received, sent }; | |
} | |
for (const request of receivedResults) { | |
received.add(request.sender); | |
} | |
return { sent, received }; | |
} | |
all(statement: string, data: any[] | AnyObject): Promise<any[] | null> { | |
return this.query({ type: 'all', data, statement }); | |
} | |
transaction(statement: string, data: any[] | AnyObject): Promise<{ result: any } | null> { | |
return this.query({ data, statement, type: 'transaction' }); | |
} | |
run(statement: string, data: any[] | AnyObject): Promise<{ changes: number, lastInsertRowid: number }> { | |
return this.query({ statement, data, type: 'run' }); | |
} | |
get(statement: string, data: any[] | AnyObject): Promise<AnyObject | null> { | |
return this.query({ statement, data, type: 'get' }); | |
} | |
private async query(input: DatabaseRequest) { | |
const process = PM.acquire(); | |
if (!process || !Config.usesqlite) { | |
return null; | |
} | |
const result = await process.query(input); | |
if (result.error) { | |
throw new Chat.ErrorMessage(result.error); | |
} | |
return result.result; | |
} | |
async request(user: User, receiverID: ID) { | |
const receiver = Users.getExact(receiverID); | |
if (receiverID === user.id || receiver?.previousIDs.includes(user.id)) { | |
throw new Chat.ErrorMessage(`You can't friend yourself.`); | |
} | |
if (receiver?.settings.blockFriendRequests) { | |
throw new Chat.ErrorMessage(`${receiver.name} is blocking friend requests.`); | |
} | |
let buf = Utils.html`/uhtml sent-${user.id},<button class="button" name="send" value="/friends accept ${user.id}">Accept</button> | `; | |
buf += Utils.html`<button class="button" name="send" value="/friends reject ${user.id}">Deny</button><br /> `; | |
buf += `<small>(You can also stop this user from sending you friend requests with <code>/ignore</code>)</small>`; | |
const disclaimer = ( | |
`/raw <small>Note: If this request is accepted, your friend will be notified when you come online, ` + | |
`and you will be notified when they do, unless you opt out of receiving them.</small>` | |
); | |
if (receiver?.settings.blockFriendRequests) { | |
throw new Chat.ErrorMessage(`This user is blocking friend requests.`); | |
} | |
if (!canPM(user, receiver)) { | |
throw new Chat.ErrorMessage(`This user is blocking PMs, and cannot be friended right now.`); | |
} | |
const result = await this.transaction('send', [user.id, receiverID]); | |
if (receiver) { | |
sendPM(`/raw <span class="username">${user.name}</span> sent you a friend request!`, receiver.id, user.id); | |
sendPM(buf, receiver.id, user.id); | |
sendPM(disclaimer, receiver.id, user.id); | |
} | |
sendPM( | |
`/nonotify You sent a friend request to ${receiver?.connected ? receiver.name : receiverID}!`, | |
user.name | |
); | |
sendPM( | |
`/uhtml undo-${receiverID},<button class="button" name="send" value="/friends undorequest ${Utils.escapeHTML(receiverID)}">` + | |
`<i class="fa fa-undo"></i> Undo</button>`, user.name | |
); | |
sendPM(disclaimer, user.id); | |
return result; | |
} | |
async removeRequest(receiverID: ID, senderID: ID) { | |
if (!senderID) throw new Chat.ErrorMessage(`Invalid sender username.`); | |
if (!receiverID) throw new Chat.ErrorMessage(`Invalid receiver username.`); | |
return this.run('deleteRequest', [senderID, receiverID]); | |
} | |
async approveRequest(receiverID: ID, senderID: ID) { | |
return this.transaction('accept', [senderID, receiverID]); | |
} | |
async removeFriend(userid: ID, friendID: ID) { | |
if (!friendID || !userid) throw new Chat.ErrorMessage(`Invalid usernames supplied.`); | |
const result = await this.run('delete', { user1: userid, user2: friendID }); | |
if (result.changes < 1) { | |
throw new Chat.ErrorMessage(`You do not have ${friendID} friended.`); | |
} | |
} | |
writeLogin(user: ID) { | |
return this.run('login', [user, Date.now(), Date.now()]); | |
} | |
hideLoginData(id: ID) { | |
return this.run('hideLogin', [id, Date.now()]); | |
} | |
allowLoginData(id: ID) { | |
return this.run('showLogin', [id]); | |
} | |
async getLastLogin(userid: ID) { | |
const result = await this.get('checkLastLogin', [userid]); | |
return parseInt(result?.['last_login']) || null; | |
} | |
async getSettings(userid: ID) { | |
return (await this.get('getSettings', [userid])) || {}; | |
} | |
setHideList(userid: ID, setting: boolean) { | |
const num = setting ? 1 : 0; | |
// name, send_login_data, last_login, public_list | |
return this.run('toggleList', [userid, num, num]); | |
} | |
async findFriendship(user1: string, user2: string): Promise<boolean> { | |
user1 = toID(user1); | |
user2 = toID(user2); | |
return !!(await this.get('findFriendship', { user1, user2 }))?.length; | |
} | |
} | |
const statements: { [k: string]: Database.Statement } = {}; | |
const transactions: { [k: string]: Database.Transaction } = {}; | |
const ACTIONS = { | |
add: ( | |
`REPLACE INTO friends (user1, user2) VALUES ($user1, $user2) ON CONFLICT (user1, user2) ` + | |
`DO UPDATE SET user1 = $user1, user2 = $user2` | |
), | |
get: ( | |
`SELECT * FROM friends_simplified f LEFT JOIN friend_settings fs ON f.friend = fs.userid WHERE f.userid = ? LIMIT ?` | |
), | |
delete: `DELETE FROM friends WHERE (user1 = $user1 AND user2 = $user2) OR (user1 = $user2 AND user2 = $user1)`, | |
getSent: `SELECT receiver, sender FROM friend_requests WHERE sender = ?`, | |
getReceived: `SELECT receiver, sender FROM friend_requests WHERE receiver = ?`, | |
insertRequest: `INSERT INTO friend_requests(sender, receiver, sent_at) VALUES (?, ?, ?)`, | |
deleteRequest: `DELETE FROM friend_requests WHERE sender = ? AND receiver = ?`, | |
deleteReceivedRequests: `DELETE FROM friend_requests WHERE receiver = ?`, | |
findFriendship: `SELECT * FROM friends WHERE (user1 = $user1 AND user2 = $user2) OR (user2 = $user1 AND user1 = $user2)`, | |
findRequest: ( | |
`SELECT count(*) as num FROM friend_requests WHERE ` + | |
`(sender = $user1 AND receiver = $user2) OR (sender = $user2 AND receiver = $user1)` | |
), | |
countRequests: `SELECT count(*) as num FROM friend_requests WHERE (sender = ? OR receiver = ?)`, | |
login: ( | |
`INSERT INTO friend_settings (userid, send_login_data, last_login, public_list) VALUES (?, 0, ?, 0) ` + | |
`ON CONFLICT (userid) DO UPDATE SET last_login = ?` | |
), | |
checkLastLogin: `SELECT last_login FROM friend_settings WHERE userid = ?`, | |
deleteLogin: `UPDATE friend_settings SET last_login = 0 WHERE userid = ?`, | |
expire: ( | |
`DELETE FROM friend_requests WHERE EXISTS` + | |
`(SELECT sent_at FROM friend_requests WHERE should_expire(sent_at) = 1)` | |
), | |
hideLogin: ( // this works since if the insert works, they have no data, which means no public_list | |
`INSERT INTO friend_settings (userid, send_login_data, last_login, public_list) VALUES (?, 1, ?, 0) ` + | |
`ON CONFLICT (userid) DO UPDATE SET send_login_data = 1` | |
), | |
showLogin: `DELETE FROM friend_settings WHERE userid = ? AND send_login_data = 1`, | |
countFriends: `SELECT count(*) as num FROM friends WHERE (user1 = ? OR user2 = ?)`, | |
getSettings: `SELECT * FROM friend_settings WHERE userid = ?`, | |
toggleList: ( | |
`INSERT INTO friend_settings (userid, send_login_data, last_login, public_list) VALUES (?, 0, 0, ?) ` + | |
`ON CONFLICT (userid) DO UPDATE SET public_list = ?` | |
), | |
}; | |
const FUNCTIONS: { [k: string]: (...input: any[]) => any } = { | |
'should_expire': (sentTime: number) => { | |
if (Date.now() - sentTime > REQUEST_EXPIRY_TIME) return 1; | |
return 0; | |
}, | |
}; | |
const TRANSACTIONS: { [k: string]: (input: any[]) => DatabaseResult } = { | |
send: requests => { | |
for (const request of requests) { | |
const [senderID, receiverID] = request; | |
const hasSentRequest = ( | |
statements.findRequest.get({ user1: senderID, user2: receiverID }) as AnyObject | |
)['num']; | |
const friends = (statements.countFriends.get(senderID, senderID) as AnyObject)['num']; | |
const totalRequests = (statements.countRequests.get(senderID, senderID) as AnyObject)['num']; | |
if (friends >= MAX_FRIENDS) { | |
throw new FailureMessage(`You are at the maximum number of friends.`); | |
} | |
const existingFriendship = statements.findFriendship.all({ user1: senderID, user2: receiverID }); | |
if (existingFriendship.length) { | |
throw new FailureMessage(`You are already friends with '${receiverID}'.`); | |
} | |
if (hasSentRequest) { | |
throw new FailureMessage(`You have already sent a friend request to '${receiverID}'.`); | |
} | |
if (totalRequests >= MAX_REQUESTS) { | |
throw new FailureMessage( | |
`You already have ${MAX_REQUESTS} pending friend requests. Use "/friends view sent" to see your outgoing requests and "/friends view receive" to see your incoming requests.` | |
); | |
} | |
statements.insertRequest.run(senderID, receiverID, Date.now()); | |
} | |
return { result: [] }; | |
}, | |
add: requests => { | |
for (const request of requests) { | |
const [senderID, receiverID] = request; | |
statements.add.run({ user1: senderID, user2: receiverID }); | |
} | |
return { result: [] }; | |
}, | |
accept: requests => { | |
for (const request of requests) { | |
const [senderID, receiverID] = request; | |
const friends = statements.get.all(receiverID, 101); | |
if (friends?.length >= MAX_FRIENDS) { | |
throw new FailureMessage(`You are at the maximum number of friends.`); | |
} | |
const { result } = TRANSACTIONS.removeRequest([request]); | |
if (!result.length) throw new FailureMessage(`You have no request pending from ${senderID}.`); | |
TRANSACTIONS.add([request]); | |
} | |
return { result: [] }; | |
}, | |
removeRequest: requests => { | |
const result = []; | |
for (const request of requests) { | |
const [to, from] = request; | |
const { changes } = statements.deleteRequest.run(to, from); | |
if (changes) result.push(changes); | |
} | |
return { result }; | |
}, | |
}; | |
/** | |
* API STUFF - For use in other database child processes that may want to interface with the friends list. | |
* todo: should these be under a namespace? | |
*/ | |
/** Find if a friendship exists between two users. */ | |
export function findFriendship(users: [string, string]) { | |
setup(); | |
return !!statements.findFriendship.get({ user1: users[0], user2: users[1] }); | |
} | |
// internal for child process api - ensures statements are only set up | |
const setup = () => { | |
if (!process.send) throw new Error("You should not be using this function in the main process"); | |
if (!Object.keys(statements).length) FriendsDatabase.setupDatabase(); | |
}; | |
/** Process manager for main process use. */ | |
export const PM = new ProcessManager.QueryProcessManager<DatabaseRequest, DatabaseResult>(module, query => { | |
const { type, statement, data } = query; | |
const start = Date.now(); | |
const result: DatabaseResult = {}; | |
try { | |
switch (type) { | |
case 'run': | |
result.result = statements[statement].run(data); | |
break; | |
case 'get': | |
result.result = statements[statement].get(data); | |
break; | |
case 'transaction': | |
result.result = transactions[statement]([data]); | |
break; | |
case 'all': | |
result.result = statements[statement].all(data); | |
break; | |
} | |
} catch (e: any) { | |
if (!e.name.endsWith('FailureMessage')) { | |
result.error = "Sorry! The database process crashed. We've been notified and will fix this."; | |
Monitor.crashlog(e, "A friends database process", query); | |
} else { | |
result.error = e.message; | |
} | |
return result; | |
} | |
const delta = Date.now() - start; | |
if (delta > 1000) { | |
Monitor.slow(`[Slow friends list query] ${JSON.stringify(query)}`); | |
} | |
return result; | |
}, PM_TIMEOUT, message => { | |
if (message.startsWith('SLOW\n')) { | |
Monitor.slow(message.slice(5)); | |
} | |
}); | |
if (require.main === module) { | |
global.Config = (require as any)('./config-loader').Config; | |
if (Config.usesqlite) { | |
FriendsDatabase.setupDatabase(); | |
} | |
// since we require this in child processes | |
if (process.mainModule === module) { | |
global.Monitor = { | |
crashlog(error: Error, source = 'A friends database process', details: AnyObject | null = null) { | |
const repr = JSON.stringify([error.name, error.message, source, details]); | |
process.send!(`THROW\n@!!@${repr}\n${error.stack}`); | |
}, | |
slow(message: string) { | |
process.send!(`CALLBACK\nSLOW\n${message}`); | |
}, | |
} as any; | |
process.on('uncaughtException', err => { | |
if (Config.crashguard) { | |
Monitor.crashlog(err, 'A friends child process'); | |
} | |
}); | |
// eslint-disable-next-line no-eval | |
Repl.start(`friends-${process.pid}`, cmd => eval(cmd)); | |
} | |
} else if (!process.send) { | |
PM.spawn(Config.friendsprocesses || 1); | |
} | |