gristlabs_grist-core/app/gen-server/ApiServer.ts
Dmitry S 2740884e3c (core) Improve the look and behavior of /welcome/teams page (also shown for /welcome/start)
Summary:
- Move css module for the login page css to core/, to be reusable in core/ pages.
- Move /welcome/teams implementation to WelcomeSitePicker.ts
- List users for personal sites, as well as team sites.
- Add org param to setSessionActive() API method and end endpoint, to allow
  switching the specified org to another user.
- Add a little safety to getOrgUrl() function.

Test Plan: Added a test case for the new behaviors of the /welcome/teams page.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3914
2023-06-13 20:40:59 -04:00

610 lines
25 KiB
TypeScript

import * as crypto from 'crypto';
import * as express from 'express';
import {EntityManager} from 'typeorm';
import * as cookie from 'cookie';
import {Request} from 'express';
import {ApiError} from 'app/common/ApiError';
import {FullUser} from 'app/common/LoginSessionAPI';
import {OrganizationProperties} from 'app/common/UserAPI';
import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer';
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
import {expressWrap} from 'app/server/lib/expressWrap';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import log from 'app/server/lib/log';
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {User} from './entity/User';
import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager';
import {getCookieDomain} from 'app/server/lib/gristSessions';
// Special public organization that contains examples and templates.
export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ?
`templates-${process.env.GRIST_ID_PREFIX}` :
'templates';
// exposed for testing purposes
export const Deps = {
apiKeyGenerator: () => crypto.randomBytes(20).toString('hex')
};
// Fetch the org this request was made for, or null if it isn't tied to a particular org.
// Early middleware should have put the org in the request object for us.
export function getOrgFromRequest(req: Request): string|null {
return (req as RequestWithOrg).org || null;
}
/**
* Compute the signature of the user's email address using HelpScout's secret key, to prove to
* HelpScout the user identity for identifying customer information and conversation history.
*/
function helpScoutSign(email: string): string|undefined {
const secretKey = process.env.HELP_SCOUT_SECRET_KEY_V2;
if (!secretKey) { return undefined; }
return crypto.createHmac('sha256', secretKey).update(email).digest('hex');
}
/**
* Fetch an identifier for an organization from the "oid" parameter of the request.
* - Integers are accepted, and will be compared with values in orgs.id column
* - Strings are accepted, and will be compared with values in orgs.domain column
* (or, if they match the pattern docs-NNNN, will check orgs.owner_id)
* - The special string "current" is replaced with the current org domain embedded
* in the url
* - If there is no identifier available, a 400 error is thrown.
*/
export function getOrgKey(req: Request): string|number {
let orgKey: string|null = stringParam(req.params.oid, 'oid');
if (orgKey === 'current') {
orgKey = getOrgFromRequest(req);
}
if (!orgKey) {
throw new ApiError("No organization chosen", 400);
} else if (/^\d+$/.test(orgKey)) {
return parseInt(orgKey, 10);
}
return orgKey;
}
// Adds an non-personal org with a new billingAccout, with the given name and domain.
// Returns a QueryResult with the orgId on success.
export function addOrg(
dbManager: HomeDBManager,
userId: number,
props: Partial<OrganizationProperties>,
options?: {
planType?: string,
}
): Promise<number> {
return dbManager.connection.transaction(async manager => {
const user = await manager.findOne(User, {where: {id: userId}});
if (!user) { return handleDeletedUser(); }
const query = await dbManager.addOrg(user, props, {
...options,
setUserAsOwner: false,
useNewPlan: true
}, manager);
if (query.status !== 200) { throw new ApiError(query.errMessage!, query.status); }
return query.data!;
});
}
/**
* Provides a REST API for the landing page, which returns user's workspaces, organizations and documents.
* Temporarily sqlite database is used. Later it will be changed to RDS Aurora or PostgreSQL.
*/
export class ApiServer {
/**
* Add API endpoints to the specified connection. An error handler is added to /api to make sure
* all error responses have a body in json format.
*
* Note that it expects bodyParser, userId, and jsonErrorHandler middleware to be set up outside
* to apply to these routes, and trustOrigin too for cross-domain requests.
*/
constructor(
private _app: express.Application,
private _dbManager: HomeDBManager,
private _widgetRepository: IWidgetRepository
) {
this._addEndpoints();
}
private _addEndpoints(): void {
// GET /api/orgs
// Get all organizations user may have some access to.
this._app.get('/api/orgs', expressWrap(async (req, res) => {
const userId = getUserId(req);
const domain = getOrgFromRequest(req);
const merged = Boolean(req.query.merged);
const query = merged ?
await this._dbManager.getMergedOrgs(userId, userId, domain) :
await this._dbManager.getOrgs(userId, domain);
return sendReply(req, res, query);
}));
// GET /api/workspace/:wid
// Get workspace by id, returning nested documents that user has access to.
this._app.get('/api/workspaces/:wid', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.getWorkspace(getScope(req), wsId);
return sendReply(req, res, query);
}));
// GET /api/orgs/:oid
// Get organization by id
this._app.get('/api/orgs/:oid', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const query = await this._dbManager.getOrg(getScope(req), org);
return sendReply(req, res, query);
}));
// GET /api/orgs/:oid/workspaces
// Get all workspaces and nested documents of organization that user has access to.
this._app.get('/api/orgs/:oid/workspaces', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const query = await this._dbManager.getOrgWorkspaces(getScope(req), org);
return sendReply(req, res, query);
}));
// GET /api/orgs/:oid/usage
// Get usage summary of all un-deleted documents in the organization.
// Only accessible to org owners.
this._app.get('/api/orgs/:oid/usage', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const usage = await this._dbManager.getOrgUsageSummary(getScope(req), org);
return sendOkReply(req, res, usage);
}));
// POST /api/orgs
// Body params: name (required), domain
// Create a new org.
this._app.post('/api/orgs', expressWrap(async (req, res) => {
// Don't let anonymous users end up owning organizations, it will be confusing.
// Maybe if the user has presented credentials this would be ok - but addOrg
// doesn't have access to that information yet, so punting on this.
// TODO: figure out who should be allowed to create organizations
const userId = getAuthorizedUserId(req);
const orgId = await addOrg(this._dbManager, userId, req.body);
return sendOkReply(req, res, orgId);
}));
// PATCH /api/orgs/:oid
// Body params: name, domain
// Update the specified org.
this._app.patch('/api/orgs/:oid', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const query = await this._dbManager.updateOrg(getScope(req), org, req.body);
return sendReply(req, res, query);
}));
// // DELETE /api/orgs/:oid
// Delete the specified org and all included workspaces and docs.
this._app.delete('/api/orgs/:oid', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const query = await this._dbManager.deleteOrg(getScope(req), org);
return sendReply(req, res, query);
}));
// POST /api/orgs/:oid/workspaces
// Body params: name
// Create a new workspace owned by the specific organization.
this._app.post('/api/orgs/:oid/workspaces', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const query = await this._dbManager.addWorkspace(getScope(req), org, req.body);
return sendReply(req, res, query);
}));
// PATCH /api/workspaces/:wid
// Body params: name
// Update the specified workspace.
this._app.patch('/api/workspaces/:wid', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.updateWorkspace(getScope(req), wsId, req.body);
return sendReply(req, res, query);
}));
// DELETE /api/workspaces/:wid
// Delete the specified workspace and all included docs.
this._app.delete('/api/workspaces/:wid', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.deleteWorkspace(getScope(req), wsId);
return sendReply(req, res, query);
}));
// POST /api/workspaces/:wid/remove
// Soft-delete the specified workspace. If query parameter "permanent" is set,
// delete permanently.
this._app.post('/api/workspaces/:wid/remove', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid');
if (isParameterOn(req.query.permanent)) {
const query = await this._dbManager.deleteWorkspace(getScope(req), wsId);
return sendReply(req, res, query);
} else {
await this._dbManager.softDeleteWorkspace(getScope(req), wsId);
return sendOkReply(req, res);
}
}));
// POST /api/workspaces/:wid/unremove
// Recover the specified workspace if it was previously soft-deleted and is
// still available.
this._app.post('/api/workspaces/:wid/unremove', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid');
await this._dbManager.undeleteWorkspace(getScope(req), wsId);
return sendOkReply(req, res);
}));
// POST /api/workspaces/:wid/docs
// Create a new doc owned by the specific workspace.
this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.addDocument(getScope(req), wsId, req.body);
return sendReply(req, res, query);
}));
// GET /api/templates/
// Get all templates (or only featured templates if `onlyFeatured` is set).
this._app.get('/api/templates/', expressWrap(async (req, res) => {
const onlyFeatured = isParameterOn(req.query.onlyFeatured);
const query = await this._dbManager.getOrgWorkspaces(
{...getScope(req), showOnlyPinned: onlyFeatured},
TEMPLATES_ORG_DOMAIN
);
return sendReply(req, res, query);
}));
// GET /api/widgets/
// Get all widget definitions from external source.
this._app.get('/api/widgets/', expressWrap(async (req, res) => {
const widgetList = await this._widgetRepository.getWidgets();
return sendOkReply(req, res, widgetList);
}));
// PATCH /api/docs/:did
// Update the specified doc.
this._app.patch('/api/docs/:did', expressWrap(async (req, res) => {
const query = await this._dbManager.updateDocument(getDocScope(req), req.body);
return sendReply(req, res, query);
}));
// POST /api/docs/:did/unremove
// Recover the specified doc if it was previously soft-deleted and is
// still available.
this._app.post('/api/docs/:did/unremove', expressWrap(async (req, res) => {
await this._dbManager.undeleteDocument(getDocScope(req));
return sendOkReply(req, res);
}));
// PATCH /api/orgs/:oid/access
// Update the specified org acl rules.
this._app.patch('/api/orgs/:oid/access', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const delta = req.body.delta;
const query = await this._dbManager.updateOrgPermissions(getScope(req), org, delta);
return sendReply(req, res, query);
}));
// PATCH /api/workspaces/:wid/access
// Update the specified workspace acl rules.
this._app.patch('/api/workspaces/:wid/access', expressWrap(async (req, res) => {
const workspaceId = integerParam(req.params.wid, 'wid');
const delta = req.body.delta;
const query = await this._dbManager.updateWorkspacePermissions(getScope(req), workspaceId, delta);
return sendReply(req, res, query);
}));
// GET /api/docs/:did
// Get information about a document.
this._app.get('/api/docs/:did', expressWrap(async (req, res) => {
const query = await this._dbManager.getDoc(req);
return sendOkReply(req, res, query);
}));
// PATCH /api/docs/:did/access
// Update the specified doc acl rules.
this._app.patch('/api/docs/:did/access', expressWrap(async (req, res) => {
const delta = req.body.delta;
const query = await this._dbManager.updateDocPermissions(getDocScope(req), delta);
return sendReply(req, res, query);
}));
// PATCH /api/docs/:did/move
// Move the doc to the workspace specified in the body.
this._app.patch('/api/docs/:did/move', expressWrap(async (req, res) => {
const workspaceId = req.body.workspace;
const query = await this._dbManager.moveDoc(getDocScope(req), workspaceId);
return sendReply(req, res, query);
}));
this._app.patch('/api/docs/:did/pin', expressWrap(async (req, res) => {
const query = await this._dbManager.pinDoc(getDocScope(req), true);
return sendReply(req, res, query);
}));
this._app.patch('/api/docs/:did/unpin', expressWrap(async (req, res) => {
const query = await this._dbManager.pinDoc(getDocScope(req), false);
return sendReply(req, res, query);
}));
// GET /api/orgs/:oid/access
// Get user access information regarding an org
this._app.get('/api/orgs/:oid/access', expressWrap(async (req, res) => {
const org = getOrgKey(req);
const query = await this._withSupportUserAllowedToView(
org, req, (scope) => this._dbManager.getOrgAccess(scope, org)
);
return sendReply(req, res, query);
}));
// GET /api/workspaces/:wid/access
// Get user access information regarding a workspace
this._app.get('/api/workspaces/:wid/access', expressWrap(async (req, res) => {
const workspaceId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.getWorkspaceAccess(getScope(req), workspaceId);
return sendReply(req, res, query);
}));
// GET /api/docs/:did/access
// Get user access information regarding a doc
this._app.get('/api/docs/:did/access', expressWrap(async (req, res) => {
const query = await this._dbManager.getDocAccess(getDocScope(req));
return sendReply(req, res, query);
}));
// GET /api/profile/user
// Get user's profile
this._app.get('/api/profile/user', expressWrap(async (req, res) => {
const fullUser = await this._getFullUser(req);
return sendOkReply(req, res, fullUser, {allowedFields: new Set(['allowGoogleLogin'])});
}));
// POST /api/profile/user/name
// Body params: string
// Update users profile.
this._app.post('/api/profile/user/name', expressWrap(async (req, res) => {
const userId = getAuthorizedUserId(req);
if (!(req.body && req.body.name)) {
throw new ApiError('Name expected in the body', 400);
}
const name = req.body.name;
await this._dbManager.updateUserName(userId, name);
res.sendStatus(200);
}));
// POST /api/profile/user/locale
// Body params: string
// Update users profile.
this._app.post('/api/profile/user/locale', expressWrap(async (req, res) => {
const userId = getAuthorizedUserId(req);
await this._dbManager.updateUserOptions(userId, {locale: req.body.locale || null});
res.append('Set-Cookie', cookie.serialize('grist_user_locale', req.body.locale || '', {
httpOnly: false, // make available to client-side scripts
domain: getCookieDomain(req),
path: '/',
secure: true,
maxAge: req.body.locale ? 31536000 : 0,
sameSite: 'None', // there is no security concern to expose this information.
}));
res.sendStatus(200);
}));
// POST /api/profile/allowGoogleLogin
// Update user's preference for allowing Google login.
this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => {
const userId = getAuthorizedUserId(req);
const fullUser = await this._getFullUser(req);
if (fullUser.loginMethod !== 'Email + Password') {
throw new ApiError('Only users signed in via email can enable/disable Google login', 401);
}
const allowGoogleLogin: boolean | undefined = req.body.allowGoogleLogin;
if (allowGoogleLogin === undefined) {
throw new ApiError('Missing body param: allowGoogleLogin', 400);
}
await this._dbManager.updateUserOptions(userId, {allowGoogleLogin});
res.sendStatus(200);
}));
this._app.post('/api/profile/isConsultant', expressWrap(async (req, res) => {
const userId = getAuthorizedUserId(req);
if (userId !== this._dbManager.getSupportUserId()) {
throw new ApiError('Only support user can enable/disable isConsultant', 401);
}
const isConsultant: boolean | undefined = req.body.isConsultant;
const targetUserId: number | undefined = req.body.userId;
if (isConsultant === undefined) {
throw new ApiError('Missing body param: isConsultant', 400);
}
if (targetUserId === undefined) {
throw new ApiError('Missing body param: targetUserId', 400);
}
await this._dbManager.updateUserOptions(targetUserId, {
isConsultant
});
res.sendStatus(200);
}));
// GET /api/profile/apikey
// Get user's apiKey
this._app.get('/api/profile/apikey', expressWrap(async (req, res) => {
const userId = getUserId(req);
const user = await User.findOne({where: {id: userId}});
if (user) {
// The null value is of no interest to the user, let's show empty string instead.
res.send(user.apiKey || '');
return;
}
handleDeletedUser();
}));
// POST /api/profile/apikey
// Update user's apiKey
this._app.post('/api/profile/apikey', expressWrap(async (req, res) => {
const userId = getAuthorizedUserId(req);
const force = req.body ? req.body.force : false;
const manager = this._dbManager.connection.manager;
let user = await manager.findOne(User, {where: {id: userId}});
if (!user) { return handleDeletedUser(); }
if (!user.apiKey || force) {
user = await updateApiKeyWithRetry(manager, user);
res.status(200).send(user.apiKey);
} else {
res.status(400).send({error: "An apikey is already set, use `{force: true}` to override it."});
}
}));
// DELETE /api/profile/apiKey
// Delete apiKey
this._app.delete('/api/profile/apikey', expressWrap(async (req, res) => {
const userId = getAuthorizedUserId(req);
await this._dbManager.connection.transaction(async manager => {
const user = await manager.findOne(User, {where: {id: userId}});
if (!user) { return handleDeletedUser(); }
user.apiKey = null;
await manager.save(User, user);
});
res.sendStatus(200);
}));
// GET /api/session/access/active
// Returns active user and active org (if any)
this._app.get('/api/session/access/active', expressWrap(async (req, res) => {
const fullUser = await this._getFullUser(req, {includePrefs: true});
const domain = getOrgFromRequest(req);
const org = domain ? (await this._withSupportUserAllowedToView(
domain, req, (scope) => this._dbManager.getOrg(scope, domain)
)) : null;
const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined;
return sendOkReply(req, res, {
user: {...fullUser, helpScoutSignature: helpScoutSign(fullUser.email)},
org: (org && org.data) || null,
orgError
});
}));
// POST /api/session/access/active
// Body params: email (required)
// Body params: org (optional) - string subdomain or 'current', for which org's active user to modify.
// Sets active user for active org
this._app.post('/api/session/access/active', expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
let domain = optStringParam(req.body.org);
if (!domain || domain === 'current') {
domain = getOrgFromRequest(mreq) || '';
}
const email = req.body.email;
if (!email) { throw new ApiError('email required', 400); }
try {
// Modify session copy in request. Will be saved to persistent storage before responding
// by express-session middleware.
linkOrgWithEmail(mreq.session, req.body.email, domain);
clearSessionCacheIfNeeded(req, {sessionID: mreq.sessionID});
return sendOkReply(req, res, {email});
} catch (e) {
throw new ApiError('email not available', 403);
}
}));
// GET /api/session/access/all
// Returns all user profiles (with ids) and all orgs they can access.
// Flattens personal orgs into a single org.
this._app.get('/api/session/access/all', expressWrap(async (req, res) => {
const domain = getOrgFromRequest(req);
const users = getUserProfiles(req);
const userId = getUserId(req);
const orgs = await this._dbManager.getMergedOrgs(userId, users, domain);
if (orgs.errMessage) { throw new ApiError(orgs.errMessage, orgs.status); }
return sendOkReply(req, res, {
users: await this._dbManager.completeProfiles(users),
orgs: orgs.data
});
}));
// DELETE /users/:uid
// Delete the specified user, their personal organization, removing them from all groups.
// Not available to the anonymous user.
// TODO: should orphan orgs, inaccessible by anyone else, get deleted when last user
// leaves?
this._app.delete('/api/users/:uid', expressWrap(async (req, res) => {
const userIdToDelete = parseInt(req.params.uid, 10);
if (!(req.body && req.body.name !== undefined)) {
throw new ApiError('to confirm deletion of a user, provide their name', 400);
}
const query = await this._dbManager.deleteUser(getScope(req), userIdToDelete, req.body.name);
return sendReply(req, res, query);
}));
}
private async _getFullUser(req: Request, options: {includePrefs?: boolean} = {}): Promise<FullUser> {
const mreq = req as RequestWithLogin;
const userId = getUserId(mreq);
const user = await this._dbManager.getUser(userId, options);
if (!user) { throw new ApiError("unable to find user", 400); }
const fullUser = this._dbManager.makeFullUser(user);
const domain = getOrgFromRequest(mreq);
const sessionUser = getSessionUser(mreq.session, domain || '', fullUser.email);
const loginMethod = sessionUser && sessionUser.profile ? sessionUser.profile.loginMethod : undefined;
const allowGoogleLogin = user.options?.allowGoogleLogin ?? true;
return {...fullUser, loginMethod, allowGoogleLogin};
}
/**
* Run a query, and, if it is denied and the user is the support
* user, rerun the query with permission to view the current
* org. This is a bit inefficient, but only affects the support
* user. We wait to add the special permission only if needed, since
* it will in fact override any other access the support user has
* been granted, which could reduce their apparent access if that is
* part of what is returned by the query.
*/
private async _withSupportUserAllowedToView<T>(
org: string|number, req: express.Request,
op: (scope: Scope) => Promise<QueryResult<T>>
): Promise<QueryResult<T>> {
const scope = getScope(req);
const userId = getUserId(req);
const result = await op(scope);
if (result.status === 200 || userId !== this._dbManager.getSupportUserId()) {
return result;
}
const extendedScope = addPermit(scope, this._dbManager.getSupportUserId(), {org});
return await op(extendedScope);
}
}
/**
* Throw the error for when a user has been deleted since point of call (very unlikely to happen).
*/
function handleDeletedUser(): never {
throw new ApiError("user not known", 401);
}
/**
* Helper to update a user's apiKey. Update might fail because of the DB uniqueness constraint on
* the apiKey (although it is very unlikely according to `crypto`), we retry until success. Fails
* after 5 unsuccessful attempts.
*/
async function updateApiKeyWithRetry(manager: EntityManager, user: User): Promise<User> {
const currentKey = user.apiKey;
for (let i = 0; i < 5; ++i) {
user.apiKey = Deps.apiKeyGenerator();
try {
// if new key is the same as the current, the db update won't fail so we check it here (very
// unlikely to happen but but still better to handle)
if (user.apiKey === currentKey) {
throw new Error('the new key is the same as the current key');
}
return await manager.save(User, user);
} catch (e) {
// swallow and retry
log.warn(`updateApiKeyWithRetry: failed attempt ${i}/5, %s`, e);
}
}
throw new Error('Could not generate a valid api key.');
}