Files
outline/server/models/Share.ts
T
Copilot 7ed41eadc6 Add per-share branding: title and logoUrl overrides (#12003)
* feat: add title and logoUrl to Share model

Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* fix: use STRING(4096) for logoUrl column in migration

Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* feat: use share title and logoUrl to override team branding on shared page

Agent-Logs-Url: https://github.com/outline/outline/sessions/854d6d22-e80b-4673-b3b2-0f9cf43a3246

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor: use ShareValidation class constants for title/logoUrl max lengths

Agent-Logs-Url: https://github.com/outline/outline/sessions/ea462d6a-d4d3-4882-ab8e-88060bf64877

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* fix: use ShareValidation constants in @Length msg template literals

Agent-Logs-Url: https://github.com/outline/outline/sessions/694116c2-47e8-4001-a103-c8a62c7ac71e

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* feat: add display settings popover with custom title and icon for shares

Move share toggles (search indexing, email subscriptions, show last
modified, show TOC) into a popover triggered by a settings cog. The
popover also includes inputs for a custom site title and icon upload
to override team branding on shared pages. Rename logoUrl to iconUrl,
loosen URL validation to allow relative attachment paths, and surface
the popover in the shared page header for users with edit permission.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* styling

* Display branding on single shared pages

* Review comments

* refactor

* PR feedback

* Lose 'Remove icon' button

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:23:13 -04:00

264 lines
5.3 KiB
TypeScript

import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { type SaveOptions } from "sequelize";
import {
ForeignKey,
BelongsTo,
Column,
DefaultScope,
Table,
Scopes,
DataType,
Default,
AllowNull,
Is,
Unique,
BeforeUpdate,
} from "sequelize-typescript";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { ShareValidation } from "@shared/validations";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import type { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import IsFQDN from "./validators/IsFQDN";
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
import Length from "./validators/Length";
@DefaultScope(() => ({
include: [
{
association: "user",
paranoid: false,
},
{
association: "collection",
required: false,
},
{
association: "document",
required: false,
},
{
association: "team",
required: true,
},
],
}))
@Scopes(() => ({
withCollectionPermissions: (userId: string) => ({
include: [
{
attributes: [
"id",
"name",
"permission",
"sharing",
"urlId",
"teamId",
"deletedAt",
],
model: Collection.scope({
method: ["withMembership", userId],
}),
as: "collection",
},
{
model: Document.scope([
"withDrafts",
{
method: ["withMembership", userId],
},
]),
paranoid: true,
as: "document",
include: [
{
attributes: [
"id",
"name",
"permission",
"urlId",
"sharing",
"teamId",
"deletedAt",
],
model: Collection.scope({
method: ["withMembership", userId],
}),
as: "collection",
},
],
},
{
association: "user",
paranoid: false,
},
{
association: "team",
},
],
}),
}))
@Table({ tableName: "shares", modelName: "share" })
@Fix
class Share extends IdModel<
InferAttributes<Share>,
Partial<InferCreationAttributes<Share>>
> {
@Column
published: boolean;
@Column
includeChildDocuments: boolean;
@Column
revokedAt: Date | null;
@Column
lastAccessedAt: Date | null;
/** Total count of times the shared link has been accessed */
@Default(0)
@Column
views: number;
@AllowNull
@Is({
args: UrlHelper.SHARE_URL_SLUG_REGEX,
msg: "Must be only alphanumeric and dashes",
})
@Column
urlId: string | null | undefined;
@Unique
@Length({ max: 255, msg: "domain must be 255 characters or less" })
@IsFQDN
@Column
domain: string | null;
@Default(false)
@Column
allowIndexing: boolean;
@Default(true)
@Column
allowSubscriptions: boolean;
@Default(false)
@Column
showLastUpdated: boolean;
@Default(false)
@Column
showTOC: boolean;
@AllowNull
@Length({
max: ShareValidation.maxTitleLength,
msg: `title must be ${ShareValidation.maxTitleLength} characters or less`,
})
@Column
title: string | null;
@AllowNull
@IsUrlOrRelativePath
@Length({
max: ShareValidation.maxIconUrlLength,
msg: `iconUrl must be ${ShareValidation.maxIconUrlLength} characters or less`,
})
@Column
iconUrl: string | null;
// hooks
@BeforeUpdate
static async checkDomain(model: Share, options: SaveOptions) {
if (!model.domain) {
return model;
}
model.domain = model.domain.toLowerCase();
const count = await Team.count({
...options,
where: {
domain: model.domain,
},
});
if (count > 0) {
throw ValidationError("Domain is already in use");
}
return model;
}
// getters
get isRevoked() {
return !!this.revokedAt;
}
get canonicalUrl() {
if (this.domain) {
const url = new URL(env.URL);
return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`;
}
return this.urlId
? `${this.team.url}/s/${this.urlId}`
: `${this.team.url}/s/${this.id}`;
}
// associations
@BelongsTo(() => User, "revokedById")
revokedBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
revokedById: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string | null;
@BelongsTo(() => Document, "documentId")
document: Document | null;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string | null;
revoke(ctx: APIContext) {
const { user } = ctx.state.auth;
this.revokedAt = new Date();
this.revokedById = user.id;
return this.saveWithCtx(ctx, undefined, { name: "revoke" });
}
}
export default Share;