mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
484 lines
12 KiB
TypeScript
484 lines
12 KiB
TypeScript
import { Request, Response, Router } from 'express';
|
|
import {
|
|
AccountStatus,
|
|
AccountUpdateInput,
|
|
AccountUpdateOutput,
|
|
EmailLoginInput,
|
|
EmailRegisterInput,
|
|
GoogleLoginInput,
|
|
GoogleUserInfo,
|
|
LoginOutput,
|
|
} from '@/types/accounts';
|
|
import axios from 'axios';
|
|
import { ApiError, NeuronRequest, NeuronResponse } from '@/types/api';
|
|
import { generateId, IdType } from '@/lib/id';
|
|
import { database } from '@/data/database';
|
|
import bcrypt from 'bcrypt';
|
|
import { WorkspaceOutput, WorkspaceRole } from '@/types/workspaces';
|
|
import { authMiddleware } from '@/middlewares/auth';
|
|
import { generateToken } from '@/lib/tokens';
|
|
import { mapNode } from '@/lib/nodes';
|
|
import { enqueueTask } from '@/queues/tasks';
|
|
import * as Y from 'yjs';
|
|
import { fromUint8Array, toUint8Array } from 'js-base64';
|
|
import { CompiledQuery } from 'kysely';
|
|
import { ServerNodeAttributes } from '@/types/nodes';
|
|
import { NodeUpdatedEvent } from '@/types/events';
|
|
import { enqueueEvent } from '@/queues/events';
|
|
import { SelectAccount } from '@/data/schema';
|
|
import { createDefaultWorkspace } from '@/lib/workspaces';
|
|
|
|
const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo';
|
|
const SaltRounds = 10;
|
|
|
|
export const accountsRouter = Router();
|
|
|
|
accountsRouter.post('/register/email', async (req: Request, res: Response) => {
|
|
const input: EmailRegisterInput = req.body;
|
|
const email = input.email.toLowerCase();
|
|
|
|
let existingAccount = await database
|
|
.selectFrom('accounts')
|
|
.selectAll()
|
|
.where('email', '=', email)
|
|
.executeTakeFirst();
|
|
|
|
const salt = await bcrypt.genSalt(SaltRounds);
|
|
const password = await bcrypt.hash(input.password, salt);
|
|
|
|
let account: SelectAccount | null | undefined = null;
|
|
if (existingAccount) {
|
|
if (existingAccount.status !== AccountStatus.Pending) {
|
|
return res.status(400).json({
|
|
code: ApiError.EmailAlreadyExists,
|
|
message: 'Email already exists.',
|
|
});
|
|
}
|
|
|
|
account = await database
|
|
.updateTable('accounts')
|
|
.set({
|
|
password: password,
|
|
name: input.name,
|
|
updated_at: new Date(),
|
|
status: AccountStatus.Active,
|
|
})
|
|
.where('id', '=', existingAccount.id)
|
|
.returningAll()
|
|
.executeTakeFirst();
|
|
} else {
|
|
account = await database
|
|
.insertInto('accounts')
|
|
.values({
|
|
id: generateId(IdType.Account),
|
|
name: input.name,
|
|
email: email,
|
|
password: password,
|
|
status: AccountStatus.Active,
|
|
created_at: new Date(),
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
if (!account) {
|
|
return res.status(500).json({
|
|
code: ApiError.InternalServerError,
|
|
message: 'Failed to create account.',
|
|
});
|
|
}
|
|
|
|
const output = await buildLoginOutput(account);
|
|
return res.status(200).json(output);
|
|
});
|
|
|
|
accountsRouter.post('/login/email', async (req: Request, res: Response) => {
|
|
const input: EmailLoginInput = req.body;
|
|
const email = input.email.toLowerCase();
|
|
|
|
let account = await database
|
|
.selectFrom('accounts')
|
|
.where('email', '=', email)
|
|
.selectAll()
|
|
.executeTakeFirst();
|
|
|
|
if (!account) {
|
|
return res.status(400).json({
|
|
code: ApiError.EmailOrPasswordIncorrect,
|
|
message: 'Invalid credentials.',
|
|
});
|
|
}
|
|
|
|
if (account.status === AccountStatus.Pending) {
|
|
return res.status(400).json({
|
|
code: ApiError.UserPendingActivation,
|
|
message: 'User is pending activation.',
|
|
});
|
|
}
|
|
|
|
if (!account.password) {
|
|
return res.status(400).json({
|
|
code: ApiError.EmailOrPasswordIncorrect,
|
|
message: 'Invalid credentials.',
|
|
});
|
|
}
|
|
|
|
const passwordMatch = await bcrypt.compare(input.password, account.password);
|
|
if (!passwordMatch) {
|
|
return res.status(400).json({
|
|
code: ApiError.EmailOrPasswordIncorrect,
|
|
message: 'Invalid credentials.',
|
|
});
|
|
}
|
|
|
|
const output = await buildLoginOutput(account);
|
|
return res.status(200).json(output);
|
|
});
|
|
|
|
accountsRouter.post('/login/google', async (req: Request, res: Response) => {
|
|
const input: GoogleLoginInput = req.body;
|
|
const url = `${GoogleUserInfoUrl}?access_token=${input.access_token}`;
|
|
const userInfoResponse = await axios.get(url);
|
|
|
|
if (userInfoResponse.status !== 200) {
|
|
return res.status(400).json({
|
|
code: ApiError.GoogleAuthFailed,
|
|
message: 'Failed to authenticate with Google.',
|
|
});
|
|
}
|
|
|
|
const googleUser: GoogleUserInfo = userInfoResponse.data;
|
|
|
|
if (!googleUser) {
|
|
return res.status(400).json({
|
|
code: ApiError.GoogleAuthFailed,
|
|
message: 'Failed to authenticate with Google.',
|
|
});
|
|
}
|
|
|
|
let existingAccount = await database
|
|
.selectFrom('accounts')
|
|
.where('email', '=', googleUser.email)
|
|
.selectAll()
|
|
.executeTakeFirst();
|
|
|
|
if (existingAccount) {
|
|
const attrs = existingAccount.attrs
|
|
? JSON.parse(existingAccount.attrs)
|
|
: {};
|
|
|
|
if (attrs?.googleId || existingAccount.status === AccountStatus.Pending) {
|
|
await database
|
|
.updateTable('accounts')
|
|
.set({
|
|
attrs: JSON.stringify({ googleId: googleUser.id }),
|
|
updated_at: new Date(),
|
|
status: AccountStatus.Active,
|
|
})
|
|
.where('id', '=', existingAccount.id)
|
|
.execute();
|
|
}
|
|
|
|
const output = await buildLoginOutput(existingAccount);
|
|
return res.status(200).json(output);
|
|
}
|
|
|
|
const newAccount = await database
|
|
.insertInto('accounts')
|
|
.values({
|
|
id: generateId(IdType.Account),
|
|
name: googleUser.name,
|
|
email: googleUser.email,
|
|
status: AccountStatus.Active,
|
|
created_at: new Date(),
|
|
password: null,
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirst();
|
|
|
|
if (!newAccount) {
|
|
return res.status(500).json({
|
|
code: ApiError.InternalServerError,
|
|
message: 'Failed to create account.',
|
|
});
|
|
}
|
|
|
|
const output = await buildLoginOutput(newAccount);
|
|
return res.status(200).json(output);
|
|
});
|
|
|
|
accountsRouter.delete(
|
|
'/logout',
|
|
authMiddleware,
|
|
async (req: NeuronRequest, res: NeuronResponse) => {
|
|
if (!req.account) {
|
|
return res.status(401).json({
|
|
code: ApiError.Unauthorized,
|
|
message: 'Unauthorized.',
|
|
});
|
|
}
|
|
|
|
await database
|
|
.deleteFrom('devices')
|
|
.where('id', '=', req.account.deviceId)
|
|
.execute();
|
|
|
|
await enqueueTask({
|
|
type: 'clean_device_data',
|
|
deviceId: req.account.deviceId,
|
|
});
|
|
|
|
return res.status(200).end();
|
|
},
|
|
);
|
|
|
|
accountsRouter.put(
|
|
'/:id',
|
|
authMiddleware,
|
|
async (req: NeuronRequest, res: NeuronResponse) => {
|
|
const id = req.params.id;
|
|
const input: AccountUpdateInput = req.body;
|
|
|
|
if (!req.account) {
|
|
return res.status(401).json({
|
|
code: ApiError.Unauthorized,
|
|
message: 'Unauthorized.',
|
|
});
|
|
}
|
|
|
|
if (id !== req.account.id) {
|
|
return res.status(400).json({
|
|
code: ApiError.BadRequest,
|
|
message: 'Invalid account id.',
|
|
});
|
|
}
|
|
|
|
const account = await database
|
|
.selectFrom('accounts')
|
|
.where('id', '=', req.account.id)
|
|
.selectAll()
|
|
.executeTakeFirst();
|
|
|
|
if (!account) {
|
|
return res.status(404).json({
|
|
code: ApiError.ResourceNotFound,
|
|
message: 'Account not found.',
|
|
});
|
|
}
|
|
|
|
const nameChanged = account.name !== input.name;
|
|
const avatarChanged = account.avatar !== input.avatar;
|
|
|
|
if (!nameChanged && !avatarChanged) {
|
|
return res.status(400).json({
|
|
code: ApiError.BadRequest,
|
|
message: 'Nothing to update.',
|
|
});
|
|
}
|
|
|
|
const users = await database
|
|
.selectFrom('nodes')
|
|
.selectAll()
|
|
.where(
|
|
'id',
|
|
'in',
|
|
database
|
|
.selectFrom('workspace_users')
|
|
.select('id')
|
|
.where('account_id', '=', req.account.id),
|
|
)
|
|
.execute();
|
|
|
|
const updates: CompiledQuery[] = [];
|
|
const events: NodeUpdatedEvent[] = [];
|
|
|
|
updates.push(
|
|
database
|
|
.updateTable('accounts')
|
|
.set({
|
|
name: input.name,
|
|
avatar: input.avatar,
|
|
updated_at: new Date(),
|
|
})
|
|
.where('id', '=', req.account.id)
|
|
.compile(),
|
|
);
|
|
|
|
for (const user of users) {
|
|
const name = user.attributes.name;
|
|
const avatar = user.attributes.avatar;
|
|
|
|
if (account.name !== name || account.avatar !== avatar) {
|
|
continue;
|
|
}
|
|
|
|
const doc = new Y.Doc({
|
|
guid: user.id,
|
|
});
|
|
Y.applyUpdate(doc, toUint8Array(user.state));
|
|
|
|
const attributesMap = doc.getMap('attributes');
|
|
if (name != input.name) {
|
|
attributesMap.set('name', input.name);
|
|
}
|
|
|
|
if (avatar != input.avatar) {
|
|
attributesMap.set('avatar', input.avatar);
|
|
}
|
|
|
|
const attributes = attributesMap.toJSON() as ServerNodeAttributes;
|
|
const attributesJson = JSON.stringify(attributes);
|
|
const encodedState = fromUint8Array(Y.encodeStateAsUpdate(doc));
|
|
|
|
const updatedAt = new Date();
|
|
const versionId = generateId(IdType.Version);
|
|
|
|
updates.push(
|
|
database
|
|
.updateTable('nodes')
|
|
.set({
|
|
attributes: attributesJson,
|
|
state: encodedState,
|
|
updated_at: updatedAt,
|
|
updated_by: user.id,
|
|
version_id: versionId,
|
|
server_updated_at: updatedAt,
|
|
})
|
|
.where('id', '=', user.id)
|
|
.compile(),
|
|
);
|
|
|
|
const event: NodeUpdatedEvent = {
|
|
type: 'node_updated',
|
|
id: user.id,
|
|
workspaceId: user.workspace_id,
|
|
beforeAttributes: user.attributes,
|
|
afterAttributes: attributes,
|
|
updatedBy: user.id,
|
|
updatedAt: updatedAt.toISOString(),
|
|
serverUpdatedAt: updatedAt.toISOString(),
|
|
versionId,
|
|
};
|
|
|
|
events.push(event);
|
|
}
|
|
|
|
await database.transaction().execute(async (trx) => {
|
|
for (const update of updates) {
|
|
await trx.executeQuery(update);
|
|
}
|
|
});
|
|
|
|
for (const event of events) {
|
|
await enqueueEvent(event);
|
|
}
|
|
|
|
const output: AccountUpdateOutput = {
|
|
id: account.id,
|
|
name: input.name,
|
|
avatar: input.avatar,
|
|
};
|
|
|
|
return res.status(200).json(output);
|
|
},
|
|
);
|
|
|
|
const buildLoginOutput = async (
|
|
account: SelectAccount,
|
|
): Promise<LoginOutput> => {
|
|
let workspaceUsers = await database
|
|
.selectFrom('workspace_users')
|
|
.where('account_id', '=', account.id)
|
|
.selectAll()
|
|
.execute();
|
|
|
|
if (workspaceUsers.length === 0) {
|
|
await createDefaultWorkspace(account);
|
|
|
|
workspaceUsers = await database
|
|
.selectFrom('workspace_users')
|
|
.where('account_id', '=', account.id)
|
|
.selectAll()
|
|
.execute();
|
|
}
|
|
|
|
const workspaceOutputs: WorkspaceOutput[] = [];
|
|
if (workspaceUsers.length > 0) {
|
|
const workspaceIds = workspaceUsers.map((wu) => wu.workspace_id);
|
|
const workspaces = await database
|
|
.selectFrom('workspaces')
|
|
.where('id', 'in', workspaceIds)
|
|
.selectAll()
|
|
.execute();
|
|
|
|
const userIds = workspaceUsers.map((wu) => wu.id);
|
|
const userNodes = await database
|
|
.selectFrom('nodes')
|
|
.selectAll()
|
|
.where('id', 'in', userIds)
|
|
.execute();
|
|
|
|
for (const workspaceUser of workspaceUsers) {
|
|
const workspace = workspaces.find(
|
|
(w) => w.id === workspaceUser.workspace_id,
|
|
);
|
|
|
|
if (!workspace) {
|
|
continue;
|
|
}
|
|
|
|
const userNode = userNodes.find((n) => n.id === workspaceUser.id);
|
|
if (!userNode) {
|
|
continue;
|
|
}
|
|
|
|
workspaceOutputs.push({
|
|
id: workspace.id,
|
|
name: workspace.name,
|
|
versionId: workspaceUser.version_id,
|
|
avatar: workspace.avatar,
|
|
description: workspace.description,
|
|
user: {
|
|
id: workspaceUser.id,
|
|
accountId: workspaceUser.account_id,
|
|
role: workspaceUser.role as WorkspaceRole,
|
|
node: mapNode(userNode),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const deviceId = generateId(IdType.Device);
|
|
const { token, salt, hash } = generateToken(deviceId);
|
|
|
|
const device = await database
|
|
.insertInto('devices')
|
|
.values({
|
|
id: deviceId,
|
|
account_id: account.id,
|
|
token_hash: hash,
|
|
token_salt: salt,
|
|
token_generated_at: new Date(),
|
|
type: 1,
|
|
created_at: new Date(),
|
|
version: '0.1.0',
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirst();
|
|
|
|
if (!device) {
|
|
throw new Error('Failed to create device.');
|
|
}
|
|
|
|
return {
|
|
account: {
|
|
token,
|
|
id: account.id,
|
|
name: account.name,
|
|
email: account.email,
|
|
deviceId: device.id,
|
|
},
|
|
workspaces: workspaceOutputs,
|
|
};
|
|
};
|