Files
outline/server/routes/api/emojis/emojis.ts
T
Tom Moor 879d2b8198 fix: Allow connecting additional auth providers on custom domain (#12364)
* fix: Unable to link secondary auth provider on custom domain

* doc

* chore: Custom -> Apex transfer token

* Refactor, address security concerns

* Ensure OAuth intent is single-use

* Secure OAuth state actor binding

* Use scrypt for OAuth actor session binding
2026-05-16 19:56:21 -04:00

277 lines
6.7 KiB
TypeScript

import Router from "koa-router";
import type { WhereOptions } from "sequelize";
import { Op } from "sequelize";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Emoji, User, Attachment } from "@server/models";
import BaseStorage from "@server/storage/files/BaseStorage";
import { authorize } from "@server/policies";
import { presentEmoji, presentPolicies } from "@server/presenters";
import type { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
import { getTeamFromContext } from "@server/utils/passport";
import { loadPublicShare } from "@server/commands/shareLoader";
import { AuthorizationError } from "@server/errors";
const router = new Router();
router.post(
"emojis.info",
auth(),
validate(T.EmojisInfoSchema),
async (ctx: APIContext<T.EmojisInfoReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const include = [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: Attachment,
as: "attachment",
paranoid: false,
},
];
let emoji;
if (id) {
emoji = await Emoji.findByPk(id, {
rejectOnEmpty: true,
include,
});
} else if (name) {
emoji = await Emoji.findOne({
where: {
name,
teamId: user.teamId,
},
include,
rejectOnEmpty: true,
});
}
authorize(user, "read", emoji);
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.get(
"emojis.redirect",
auth({ optional: true }),
validate(T.EmojisRedirectSchema),
async (ctx: APIContext<T.EmojisRedirectReq>) => {
const { id, shareId } = ctx.input.query;
const { user } = ctx.state.auth;
const emoji = await Emoji.unscoped().findByPk(id, {
rejectOnEmpty: true,
include: [
{
model: Attachment,
},
],
});
if (shareId) {
const teamFromCtx = await getTeamFromContext(ctx, {
includeOAuthState: false,
});
const { share } = await loadPublicShare({
id: shareId,
teamId: teamFromCtx?.id,
});
// Note: This is purposefully using a somewhat looser authorization check.
// In order to load a custom emoji you must have a valid emoji ID and a
// valid share ID from the same team.
if (share.teamId !== emoji.teamId) {
throw AuthorizationError();
}
} else {
authorize(user, "read", emoji);
}
ctx.set(
"Cache-Control",
`max-age=${BaseStorage.defaultSignedUrlExpires}, immutable`
);
ctx.redirect(await emoji.attachment.signedUrl);
}
);
router.post(
"emojis.list",
auth(),
pagination(),
validate(T.EmojisListSchema),
async (ctx: APIContext<T.EmojisListReq>) => {
const { user } = ctx.state.auth;
const { query } = ctx.input.body;
let where: WhereOptions<Emoji> = {
teamId: user.teamId,
};
if (query) {
where = {
...where,
name: {
[Op.iLike]: `%${query}%`,
},
};
}
const [emojis, total] = await Promise.all([
Emoji.findAll({
where,
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: Attachment,
as: "attachment",
paranoid: false,
},
],
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Emoji.count({
where,
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: emojis.map(presentEmoji),
policies: presentPolicies(user, emojis),
};
}
);
router.post(
"emojis.create",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.EmojisCreateSchema),
transaction(),
async (ctx: APIContext<T.EmojisCreateReq>) => {
const { name, attachmentId } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const attachment = await Attachment.findByPk(attachmentId, {
transaction,
rejectOnEmpty: true,
});
authorize(user, "read", attachment);
const emoji = await Emoji.createWithCtx(ctx, {
name,
attachmentId,
teamId: user.teamId,
createdById: user.id,
createdBy: user,
});
emoji.createdBy = user;
emoji.attachment = attachment;
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.post(
"emojis.update",
auth(),
validate(T.EmojisUpdateSchema),
transaction(),
async (ctx: APIContext<T.EmojisUpdateReq>) => {
const { id, attachmentId } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const emoji = await Emoji.findByPk(id, {
transaction,
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", emoji);
const attachment = await Attachment.findByPk(attachmentId, {
transaction,
rejectOnEmpty: true,
});
authorize(user, "read", attachment);
// Capture old attachment before reassigning so we can clean it up.
const oldAttachmentId = emoji.attachmentId;
emoji.attachmentId = attachmentId;
emoji.createdById = user.id;
await emoji.save({ transaction });
if (oldAttachmentId !== attachmentId) {
const oldAttachment = await Attachment.findByPk(oldAttachmentId, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (oldAttachment) {
await oldAttachment.destroy({ transaction });
}
}
emoji.attachment = attachment;
emoji.createdBy = user;
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.post(
"emojis.delete",
auth(),
validate(T.EmojisDeleteSchema),
transaction(),
async (ctx: APIContext<T.EmojisDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const emoji = await Emoji.findByPk(id, {
transaction: ctx.state.transaction,
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", emoji);
await emoji.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
export default router;