feat: public page sharing (#1012)

* Share - WIP

* - public attachment links
- WIP

* WIP

* WIP

* Share - WIP

* WIP

* WIP

* include userRole in space object

* WIP

* Server render shared page meta tags

* disable user select

* Close Navbar on outside click on mobile

* update shared page spaceId

* WIP

* fix

* close sidebar on click

* close sidebar

* defaults

* update copy

* Store share key in lowercase

* refactor page breadcrumbs

* Change copy

* add link ref

* open link button

* add meta og:title

* add twitter tags

* WIP

* make shares/info endpoint public

* fix

* * add /p/ segment to share urls
* minore fixes

* change mobile breadcrumb icon
This commit is contained in:
Philip Okugbe
2025-04-22 20:37:32 +01:00
committed by GitHub
parent 3e8824435d
commit 6c422011ac
66 changed files with 3331 additions and 512 deletions

View File

@ -24,6 +24,7 @@ import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -74,6 +75,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
exports: [
WorkspaceRepo,
@ -88,6 +90,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
})
export class DatabaseModule

View File

@ -0,0 +1,38 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('shares')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('key', 'varchar', (col) => col.notNull())
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false))
.addColumn('search_indexing', 'boolean', (col) => col.defaultTo(false))
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('shares_key_workspace_id_unique', [
'key',
'workspace_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('shares').execute();
}

View File

@ -211,7 +211,10 @@ export class PageRepo {
).as('contributors');
}
async getPageAndDescendants(parentPageId: string) {
async getPageAndDescendants(
parentPageId: string,
opts: { includeContent: boolean },
) {
return this.db
.withRecursive('page_hierarchy', (db) =>
db
@ -221,11 +224,12 @@ export class PageRepo {
'slugId',
'title',
'icon',
'content',
'position',
'parentPageId',
'spaceId',
'workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId)
.unionAll((exp) =>
exp
@ -235,11 +239,12 @@ export class PageRepo {
'p.slugId',
'p.title',
'p.icon',
'p.content',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
),
)

View File

@ -0,0 +1,242 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertableShare,
Share,
UpdatableShare,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class ShareRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private spaceMemberRepo: SpaceMemberRepo,
) {}
private baseFields: Array<keyof Share> = [
'id',
'key',
'pageId',
'includeSubPages',
'searchIndexing',
'creatorId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
shareId: string,
opts?: {
includeSharedPage?: boolean;
includeCreator?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
): Promise<Share> {
const db = dbOrTx(this.db, opts?.trx);
let query = db.selectFrom('shares').select(this.baseFields);
if (opts?.includeSharedPage) {
query = query.select((eb) => this.withSharedPage(eb));
}
if (opts?.includeCreator) {
query = query.select((eb) => this.withCreator(eb));
}
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
if (isValidUUID(shareId)) {
query = query.where('id', '=', shareId);
} else {
query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase());
}
return query.executeTakeFirst();
}
async findByPageId(
pageId: string,
opts?: {
includeCreator?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
): Promise<Share> {
const db = dbOrTx(this.db, opts?.trx);
let query = db
.selectFrom('shares')
.select(this.baseFields)
.where('pageId', '=', pageId);
if (opts?.includeCreator) {
query = query.select((eb) => this.withCreator(eb));
}
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
return query.executeTakeFirst();
}
async updateShare(
updatableShare: UpdatableShare,
shareId: string,
trx?: KyselyTransaction,
) {
return dbOrTx(this.db, trx)
.updateTable('shares')
.set({ ...updatableShare, updatedAt: new Date() })
.where(
isValidUUID(shareId) ? 'id' : sql`LOWER(key)`,
'=',
shareId.toLowerCase(),
)
.returning(this.baseFields)
.executeTakeFirst();
}
async insertShare(
insertableShare: InsertableShare,
trx?: KyselyTransaction,
): Promise<Share> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('shares')
.values(insertableShare)
.returning(this.baseFields)
.executeTakeFirst();
}
async deleteShare(shareId: string): Promise<void> {
let query = this.db.deleteFrom('shares');
if (isValidUUID(shareId)) {
query = query.where('id', '=', shareId);
} else {
query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase());
}
await query.execute();
}
async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('pages')
.select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
.whereRef('pages.id', '=', 'shares.pageId'),
).as('page');
}
withSpace(eb: ExpressionBuilder<DB, 'shares'>, userId?: string) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
.$if(Boolean(userId), (qb) =>
qb.select((eb) => this.withUserSpaceRole(eb, userId)),
)
.whereRef('spaces.id', '=', 'shares.spaceId'),
).as('space');
}
withUserSpaceRole(eb: ExpressionBuilder<DB, 'spaces'>, userId: string) {
return eb
.selectFrom(
eb
.selectFrom('spaceMembers')
.select(['spaceMembers.role'])
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
.where('spaceMembers.userId', '=', userId)
.unionAll(
eb
.selectFrom('spaceMembers')
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.select(['spaceMembers.role'])
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
.where('groupUsers.userId', '=', userId),
)
.as('roles_union'),
)
.select('roles_union.role')
.orderBy(
sql`CASE roles_union.role
WHEN 'admin' THEN 3
WHEN 'writer' THEN 2
WHEN 'reader' THEN 1
ELSE 0
END`,
'desc',
)
.limit(1)
.as('userRole');
}
withCreator(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'shares.creatorId'),
).as('creator');
}
withSharedPage(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('pages')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.parentPageId',
])
.whereRef('pages.id', '=', 'shares.pageId'),
).as('sharedPage');
}
}

View File

@ -183,6 +183,20 @@ export interface Pages {
ydoc: Buffer | null;
}
export interface Shares {
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
id: Generated<string>;
includeSubPages: Generated<boolean | null>;
key: string;
pageId: string | null;
searchIndexing: Generated<boolean | null>;
spaceId: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>;
@ -288,6 +302,7 @@ export interface DB {
groupUsers: GroupUsers;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
users: Users;

View File

@ -16,6 +16,7 @@ import {
Billing as BillingSubscription,
AuthProviders,
AuthAccounts,
Shares,
} from './db';
// Workspace
@ -101,3 +102,8 @@ export type UpdatableAuthProvider = Updateable<Omit<AuthProviders, 'id'>>;
export type AuthAccount = Selectable<AuthAccounts>;
export type InsertableAuthAccount = Insertable<AuthAccounts>;
export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
// Share
export type Share = Selectable<Shares>;
export type InsertableShare = Insertable<Shares>;
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;