mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 03:26:47 +08:00
feat(server): synthetic root doc (#14794)
This commit is contained in:
Vendored
+7
@@ -26,6 +26,8 @@ export const AFFINE_PRO_LICENSE_AES_KEY: string | undefined | null
|
||||
|
||||
export const AFFINE_PRO_PUBLIC_KEY: string | undefined | null
|
||||
|
||||
export declare function buildPublicRootDoc(rootDocBin: Buffer, docMetas: Array<PublicDocMetaInput>): Buffer
|
||||
|
||||
export interface Chunk {
|
||||
index: number
|
||||
content: string
|
||||
@@ -120,6 +122,11 @@ export declare function parseWorkspaceDoc(docBin: Buffer): NativeWorkspaceDocCon
|
||||
|
||||
export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise<Buffer>
|
||||
|
||||
export interface PublicDocMetaInput {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export declare function readAllDocIdsFromRootDoc(docBin: Buffer, includeTrash?: boolean | undefined | null): Array<string>
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,12 @@ impl From<WorkspaceDocContent> for NativeWorkspaceDocContent {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PublicDocMetaInput {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct NativeBlockInfo {
|
||||
pub block_id: String,
|
||||
@@ -254,6 +260,19 @@ pub fn add_doc_to_root_doc(root_doc_bin: Buffer, doc_id: String, title: Option<S
|
||||
Ok(Buffer::from(result))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn build_public_root_doc(root_doc_bin: Buffer, doc_metas: Vec<PublicDocMetaInput>) -> Result<Buffer> {
|
||||
let metas = doc_metas
|
||||
.iter()
|
||||
.map(|meta| (meta.id.as_str(), meta.title.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
let result = map_napi_err(
|
||||
doc_parser::build_public_root_doc(&root_doc_bin, &metas),
|
||||
Status::GenericFailure,
|
||||
)?;
|
||||
Ok(Buffer::from(result))
|
||||
}
|
||||
|
||||
/// Updates a document title in the workspace root doc's meta.pages array.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -11,6 +11,7 @@ import { JobQueue } from '../../base';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { AuthService } from '../../core/auth';
|
||||
import { DocReader } from '../../core/doc';
|
||||
import { ContextCategories, DocRole, WorkspaceRole } from '../../models';
|
||||
import { CopilotContextService } from '../../plugins/copilot/context';
|
||||
import {
|
||||
CopilotEmbeddingJob,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
OpenAIProvider,
|
||||
} from '../../plugins/copilot/providers';
|
||||
import { CopilotStorage } from '../../plugins/copilot/storage';
|
||||
import { MockCopilotProvider } from '../mocks';
|
||||
import { MockCopilotProvider, Mockers } from '../mocks';
|
||||
import {
|
||||
acceptInviteById,
|
||||
createTestingApp,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
TestUser,
|
||||
} from '../utils';
|
||||
import {
|
||||
addContextCategory,
|
||||
addContextDoc,
|
||||
addContextFile,
|
||||
array2sse,
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
getPinnedSessions,
|
||||
getWorkspaceSessions,
|
||||
listContext,
|
||||
listContextCategories,
|
||||
listContextDocAndFiles,
|
||||
matchFiles,
|
||||
matchWorkspaceDocs,
|
||||
@@ -1040,6 +1043,114 @@ test('should be able to manage context', async t => {
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject context reads from another user', async t => {
|
||||
const { app, context, jobs, u1 } = t.context;
|
||||
|
||||
const u2 = await app.signupV1();
|
||||
await app.switchUser(u1);
|
||||
|
||||
const { id: workspaceId } = await createWorkspace(app);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
|
||||
Sinon.stub(context, 'embeddingClient').get(() => new MockEmbeddingClient());
|
||||
Sinon.stub(jobs, 'embeddingClient').get(() => new MockEmbeddingClient());
|
||||
|
||||
const contextId = await createCopilotContext(app, workspaceId, sessionId);
|
||||
await addContextFile(app, contextId, 'sample.txt', Buffer.from('test file'));
|
||||
|
||||
await app.switchUser(u2);
|
||||
|
||||
await t.throwsAsync(
|
||||
app.gql(`
|
||||
query {
|
||||
currentUser {
|
||||
copilot {
|
||||
contexts(contextId: "${contextId}") {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
);
|
||||
await t.throwsAsync(matchFiles(app, contextId, 'test', 1));
|
||||
});
|
||||
|
||||
test('should skip unauthorized docs when adding context category', async t => {
|
||||
const { app, context, jobs, u1 } = t.context;
|
||||
|
||||
const member = await app.signupV1();
|
||||
await app.switchUser(u1);
|
||||
|
||||
const { id: workspaceId } = await createWorkspace(app);
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId,
|
||||
userId: member.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
});
|
||||
|
||||
const readableSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId,
|
||||
user: u1,
|
||||
});
|
||||
const hiddenSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId,
|
||||
user: u1,
|
||||
});
|
||||
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId,
|
||||
docId: readableSnapshot.id,
|
||||
title: 'readable-doc',
|
||||
});
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId,
|
||||
docId: hiddenSnapshot.id,
|
||||
title: 'hidden-doc',
|
||||
defaultRole: DocRole.None,
|
||||
});
|
||||
|
||||
Sinon.stub(context, 'embeddingClient').get(() => new MockEmbeddingClient());
|
||||
Sinon.stub(jobs, 'embeddingClient').get(() => new MockEmbeddingClient());
|
||||
|
||||
await app.switchUser(member);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const contextId = await createCopilotContext(app, workspaceId, sessionId);
|
||||
const category = await addContextCategory(
|
||||
app,
|
||||
contextId,
|
||||
ContextCategories.Collection,
|
||||
'fav',
|
||||
[readableSnapshot.id, hiddenSnapshot.id]
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
category.docs.map(doc => doc.id),
|
||||
[readableSnapshot.id]
|
||||
);
|
||||
|
||||
const ret = await listContextCategories(
|
||||
app,
|
||||
workspaceId,
|
||||
sessionId,
|
||||
contextId
|
||||
);
|
||||
t.deepEqual(
|
||||
ret?.collections?.[0]?.docs.map(doc => doc.id),
|
||||
[readableSnapshot.id]
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to transcript', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
getRecentlyUpdatedDocsQuery,
|
||||
getWorkspacePageByIdQuery,
|
||||
type GraphQLQuery,
|
||||
publishPageMutation,
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { DocRole, WorkspaceRole } from '../../../models';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
@@ -152,3 +154,97 @@ e2e('should get doc with title and null summary', async t => {
|
||||
t.is(result.workspace.doc.title, doc.title);
|
||||
t.is(result.workspace.doc.summary, null);
|
||||
});
|
||||
|
||||
e2e('should require owner or admin to query workspace docs', async t => {
|
||||
const owner = await app.signup();
|
||||
const member = await app.createUser();
|
||||
await app.login(member);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
});
|
||||
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
title: 'private-doc',
|
||||
defaultRole: DocRole.None,
|
||||
});
|
||||
|
||||
await app.switchUser(member);
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: {
|
||||
id: 'workspaceDocsPermissionCheck',
|
||||
op: 'workspaceDocsPermissionCheck',
|
||||
query: `
|
||||
query {
|
||||
workspace(id: "${workspace.id}") {
|
||||
docs(pagination: { first: 10 }) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
} satisfies GraphQLQuery,
|
||||
variables: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
e2e('should require Doc.Read to query workspace page meta', async t => {
|
||||
const owner = await app.signup();
|
||||
const member = await app.createUser();
|
||||
await app.login(member);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
});
|
||||
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
const doc = await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
title: 'private-doc',
|
||||
defaultRole: DocRole.None,
|
||||
});
|
||||
|
||||
await app.switchUser(member);
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: {
|
||||
id: 'workspacePageMetaPermissionCheck',
|
||||
op: 'workspacePageMetaPermissionCheck',
|
||||
query: `
|
||||
query {
|
||||
workspace(id: "${workspace.id}") {
|
||||
pageMeta(pageId: "${doc.docId}") {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
} satisfies GraphQLQuery,
|
||||
variables: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,12 @@ import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import { Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS } from '../../base';
|
||||
import {
|
||||
DocRole,
|
||||
Models,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../models';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
type WebsocketResponse<T> =
|
||||
@@ -25,6 +31,16 @@ function unwrapResponse<T>(t: ExecutionContext, res: WebsocketResponse<T>): T {
|
||||
throw new Error(`Websocket error: ${res.error.name}: ${res.error.message}`);
|
||||
}
|
||||
|
||||
function getErrorResponse<T>(
|
||||
t: ExecutionContext,
|
||||
res: WebsocketResponse<T>
|
||||
): { name: string; message: string } {
|
||||
if ('error' in res) return res.error;
|
||||
|
||||
t.log(res);
|
||||
throw new Error(`Expected websocket error response, got data instead`);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
@@ -129,7 +145,7 @@ function expectNoEvent(
|
||||
}
|
||||
|
||||
async function login(app: TestingApp) {
|
||||
const user = await app.createUser('u1@affine.pro');
|
||||
const user = await app.createUser();
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: user.email, password: user.password })
|
||||
@@ -519,3 +535,66 @@ test('active users metric should dedupe multiple sockets for one user', async t
|
||||
await Promise.all([waitForDisconnect(first), waitForDisconnect(second)]);
|
||||
}
|
||||
});
|
||||
|
||||
test('workspace sync delete-doc should enforce doc permissions', async t => {
|
||||
const db = app.get(PrismaClient);
|
||||
const models = app.get(Models);
|
||||
const { user: owner } = await login(app);
|
||||
const { user: collaborator, cookieHeader } = await login(app);
|
||||
const workspace = await models.workspace.create(owner.id);
|
||||
const docId = 'private-doc';
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
collaborator.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
|
||||
await db.snapshot.create({
|
||||
data: {
|
||||
id: docId,
|
||||
workspaceId: workspace.id,
|
||||
blob: Buffer.from([1, 1]),
|
||||
state: Buffer.from([1, 1]),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: owner.id,
|
||||
updatedBy: owner.id,
|
||||
},
|
||||
});
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
|
||||
const join = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
socket,
|
||||
'space:join',
|
||||
{
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
clientVersion: '0.26.0',
|
||||
}
|
||||
)
|
||||
);
|
||||
t.true(join.success);
|
||||
|
||||
const error = getErrorResponse(
|
||||
t,
|
||||
await emitWithAck(socket, 'space:delete-doc', {
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
docId,
|
||||
})
|
||||
);
|
||||
t.true(error.message.includes('Doc.Delete'));
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContextCategories } from '../../models';
|
||||
import { PromptConfig, PromptMessage } from '../../plugins/copilot/providers';
|
||||
import { NodeExecutorType } from '../../plugins/copilot/workflow/executor';
|
||||
import {
|
||||
@@ -318,6 +319,33 @@ export async function addContextDoc(
|
||||
return res.addContextDoc;
|
||||
}
|
||||
|
||||
export async function addContextCategory(
|
||||
app: TestingApp,
|
||||
contextId: string,
|
||||
type: ContextCategories,
|
||||
categoryId: string,
|
||||
docs: string[]
|
||||
): Promise<{ type: string; id: string; docs: { id: string }[] }> {
|
||||
const graphqlType =
|
||||
type === ContextCategories.Collection ? 'Collection' : 'Tag';
|
||||
const res = await app.gql(
|
||||
`
|
||||
mutation addContextCategory($options: AddContextCategoryInput!) {
|
||||
addContextCategory(options: $options) {
|
||||
type
|
||||
id
|
||||
docs {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ options: { contextId, type: graphqlType, categoryId, docs } }
|
||||
);
|
||||
|
||||
return res.addContextCategory;
|
||||
}
|
||||
|
||||
export async function removeContextDoc(
|
||||
app: TestingApp,
|
||||
contextId: string,
|
||||
@@ -390,6 +418,50 @@ export async function listContextDocAndFiles(
|
||||
return { docs, files };
|
||||
}
|
||||
|
||||
export async function listContextCategories(
|
||||
app: TestingApp,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
contextId: string
|
||||
): Promise<
|
||||
| {
|
||||
collections: {
|
||||
type: string;
|
||||
id: string;
|
||||
docs: {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const res = await app.gql(`
|
||||
query {
|
||||
currentUser {
|
||||
copilot(workspaceId: "${workspaceId}") {
|
||||
contexts(sessionId: "${sessionId}", contextId: "${contextId}") {
|
||||
collections {
|
||||
type
|
||||
id
|
||||
docs {
|
||||
id
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const { collections } = res.currentUser?.copilot?.contexts?.[0] || {};
|
||||
|
||||
return { collections };
|
||||
}
|
||||
|
||||
export async function submitAudioTranscription(
|
||||
app: TestingApp,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { addDocToRootDoc, readAllDocIdsFromRootDoc } from '../native';
|
||||
import {
|
||||
acceptInviteById,
|
||||
createTestingApp,
|
||||
@@ -58,10 +59,15 @@ test('should be able to publish workspace', async t => {
|
||||
});
|
||||
|
||||
test('should visit public page', async t => {
|
||||
const { app } = t.context;
|
||||
await app.signupV1('u1@affine.pro');
|
||||
const { app, client } = t.context;
|
||||
const user = await app.signupV1('u1@affine.pro');
|
||||
|
||||
const workspace = await createWorkspace(app);
|
||||
const rootDoc = addDocToRootDoc(Buffer.from([0, 0]), 'doc1', 'doc1');
|
||||
await client.snapshot.update({
|
||||
where: { workspaceId_id: { workspaceId: workspace.id, id: workspace.id } },
|
||||
data: { blob: rootDoc },
|
||||
});
|
||||
const share = await publishDoc(app, workspace.id, 'doc1');
|
||||
|
||||
t.is(share.id, 'doc1', 'failed to share doc');
|
||||
@@ -78,18 +84,32 @@ test('should visit public page', async t => {
|
||||
`/api/workspaces/${workspace.id}/docs/${workspace.id}`
|
||||
);
|
||||
t.is(resp1.statusCode, 200, 'failed to get root doc with u1 token');
|
||||
|
||||
await app.logout();
|
||||
const resp2 = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/docs/${workspace.id}`
|
||||
);
|
||||
t.is(resp2.statusCode, 200, 'failed to get root doc with public pages');
|
||||
t.is(resp2.statusCode, 403, 'legacy root doc should not be public');
|
||||
|
||||
const respPublicRoot = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/public-docs/doc1/root-doc`
|
||||
);
|
||||
t.is(
|
||||
respPublicRoot.statusCode,
|
||||
200,
|
||||
'failed to get filtered public root doc'
|
||||
);
|
||||
t.deepEqual(readAllDocIdsFromRootDoc(respPublicRoot.body, false), ['doc1']);
|
||||
|
||||
const resp3 = await app.GET(`/api/workspaces/${workspace.id}/docs/doc1`);
|
||||
// 404 because we don't put the page doc to server
|
||||
t.is(resp3.statusCode, 404, 'failed to get shared doc with u1 token');
|
||||
t.is(resp3.statusCode, 404, 'failed to get shared doc without snapshot');
|
||||
const resp4 = await app.GET(`/api/workspaces/${workspace.id}/docs/doc1`);
|
||||
// 404 because we don't put the page doc to server
|
||||
t.is(resp4.statusCode, 404, 'should not get shared doc without token');
|
||||
|
||||
await app.login(user);
|
||||
|
||||
const revoke = await revokePublicDoc(app, workspace.id, 'doc1');
|
||||
t.false(revoke.public, 'failed to revoke doc');
|
||||
const docs2 = await getWorkspacePublicDocs(app, workspace.id);
|
||||
|
||||
@@ -4,10 +4,17 @@ import { HttpStatus } from '@nestjs/common';
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import supertest from 'supertest';
|
||||
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
|
||||
|
||||
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
|
||||
import {
|
||||
addDocToRootDoc,
|
||||
mergeUpdatesInApplyWay,
|
||||
readAllDocIdsFromRootDoc,
|
||||
} from '../../native';
|
||||
import { createTestingApp, TestingApp, TestUser } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
@@ -217,6 +224,81 @@ test('should be able to get doc', async t => {
|
||||
t.deepEqual(res.body, Buffer.from([0, 0]));
|
||||
});
|
||||
|
||||
test('should not expose legacy root doc for private workspace with public pages', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await app.GET('/api/workspaces/private/docs/private');
|
||||
|
||||
t.is(res.status, HttpStatus.FORBIDDEN);
|
||||
});
|
||||
|
||||
test('should expose filtered public root doc for shared page', async t => {
|
||||
const { app, workspace: doc } = t.context;
|
||||
|
||||
let root = addDocToRootDoc(Buffer.from([0, 0]), 'public', 'Public Doc');
|
||||
const privateUpdate = addDocToRootDoc(root, 'private-page', 'Private Doc');
|
||||
root = mergeUpdatesInApplyWay([root, privateUpdate]);
|
||||
|
||||
doc.getDoc.resolves({
|
||||
spaceId: 'private',
|
||||
docId: 'private',
|
||||
bin: root,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const res = await app.GET(
|
||||
'/api/workspaces/private/public-docs/public/root-doc'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
const body = Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.body);
|
||||
t.deepEqual(readAllDocIdsFromRootDoc(body, false), ['public']);
|
||||
|
||||
const ydoc = new YDoc({ guid: 'private' });
|
||||
t.notThrows(() =>
|
||||
applyUpdate(
|
||||
ydoc,
|
||||
new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
|
||||
)
|
||||
);
|
||||
const pages = (ydoc.getMap('meta') as YMap<unknown>).get('pages') as
|
||||
| { toArray: () => Array<{ get: (key: string) => unknown }> }
|
||||
| undefined;
|
||||
t.deepEqual(
|
||||
pages?.toArray().map(page => page.get('id')),
|
||||
['public']
|
||||
);
|
||||
});
|
||||
|
||||
test('should expose public doc publish mode through HEAD route', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await supertest(app.getHttpServer()).head(
|
||||
'/api/workspaces/private/public-docs/public'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('publish-mode'), 'page');
|
||||
});
|
||||
|
||||
test('should expose public doc binary through public route', async t => {
|
||||
const { app, workspace: doc } = t.context;
|
||||
|
||||
doc.getDoc.resolves({
|
||||
spaceId: 'private',
|
||||
docId: 'public',
|
||||
bin: Buffer.from([1, 2, 3]),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const res = await app.GET('/api/workspaces/private/public-docs/public');
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'application/octet-stream');
|
||||
t.is(res.get('publish-mode'), 'page');
|
||||
t.deepEqual(res.body, Buffer.from([1, 2, 3]));
|
||||
});
|
||||
|
||||
test('should record doc view when reading doc', async t => {
|
||||
const { app, workspace: doc, models } = t.context;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { User, Workspace } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import ava, { ExecutionContext, TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import { Doc as YDoc } from 'yjs';
|
||||
|
||||
@@ -11,12 +11,14 @@ import { Flavor } from '../../../env';
|
||||
import { Models } from '../../../models';
|
||||
import { DocReader, PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
interface Context {
|
||||
models: Models;
|
||||
app: TestingApp;
|
||||
adapter: PgWorkspaceDocStorageAdapter;
|
||||
docReader: DocReader;
|
||||
}>;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
// @ts-expect-error testing
|
||||
@@ -49,10 +51,11 @@ test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should render page success', async t => {
|
||||
async function createDoc(
|
||||
adapter: PgWorkspaceDocStorageAdapter,
|
||||
content: string
|
||||
) {
|
||||
const docId = randomUUID();
|
||||
const { app, adapter, models } = t.context;
|
||||
|
||||
const doc = new YDoc();
|
||||
const text = doc.getText('content');
|
||||
const updates: Buffer[] = [];
|
||||
@@ -61,11 +64,15 @@ test('should render page success', async t => {
|
||||
updates.push(Buffer.from(update));
|
||||
});
|
||||
|
||||
text.insert(0, 'hello');
|
||||
text.insert(5, 'world');
|
||||
text.insert(5, ' ');
|
||||
|
||||
text.insert(0, content);
|
||||
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
|
||||
return docId;
|
||||
}
|
||||
|
||||
test('should render page success', async t => {
|
||||
const { app, adapter, models } = t.context;
|
||||
const docId = await createDoc(adapter, 'hello world');
|
||||
|
||||
await models.doc.publish(workspace.id, docId);
|
||||
|
||||
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
||||
@@ -73,19 +80,8 @@ test('should render page success', async t => {
|
||||
});
|
||||
|
||||
test('should record page view when rendering shared page', async t => {
|
||||
const docId = randomUUID();
|
||||
const { app, adapter, models, docReader } = t.context;
|
||||
|
||||
const doc = new YDoc();
|
||||
const text = doc.getText('content');
|
||||
const updates: Buffer[] = [];
|
||||
|
||||
doc.on('update', update => {
|
||||
updates.push(Buffer.from(update));
|
||||
});
|
||||
|
||||
text.insert(0, 'analytics');
|
||||
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
|
||||
const docId = await createDoc(adapter, 'analytics');
|
||||
await models.doc.publish(workspace.id, docId);
|
||||
|
||||
const docContent = Sinon.stub(docReader, 'getDocContent').resolves({
|
||||
@@ -110,46 +106,124 @@ test('should record page view when rendering shared page', async t => {
|
||||
record.restore();
|
||||
});
|
||||
|
||||
test('should return markdown content and skip page view when accept is text/markdown', async t => {
|
||||
const docId = randomUUID();
|
||||
const { app, adapter, models, docReader } = t.context;
|
||||
const policyCases: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
expectedStatus: number;
|
||||
setup: (
|
||||
models: Models,
|
||||
docId: string,
|
||||
docReader: DocReader
|
||||
) => Promise<{
|
||||
markdown?: Sinon.SinonStub;
|
||||
docContent?: Sinon.SinonStub;
|
||||
record?: Sinon.SinonStub;
|
||||
}>;
|
||||
request: (app: TestingApp, docId: string) => ReturnType<TestingApp['GET']>;
|
||||
assert: (
|
||||
t: ExecutionContext<Context>,
|
||||
res: Awaited<ReturnType<TestingApp['GET']>>,
|
||||
stubs: {
|
||||
markdown?: Sinon.SinonStub;
|
||||
docContent?: Sinon.SinonStub;
|
||||
record?: Sinon.SinonStub;
|
||||
},
|
||||
docId: string
|
||||
) => void;
|
||||
}> = [
|
||||
{
|
||||
title:
|
||||
'should return markdown content and skip page view when accept is text/markdown',
|
||||
content: 'markdown',
|
||||
expectedStatus: 200,
|
||||
setup: async (models, docId, docReader) => {
|
||||
await models.doc.publish(workspace.id, docId);
|
||||
return {
|
||||
markdown: Sinon.stub(docReader, 'getDocMarkdown').resolves({
|
||||
title: 'markdown-doc',
|
||||
markdown: '# markdown-doc',
|
||||
knownUnsupportedBlocks: [],
|
||||
unknownBlocks: [],
|
||||
}),
|
||||
docContent: Sinon.stub(docReader, 'getDocContent'),
|
||||
record: Sinon.stub(
|
||||
models.workspaceAnalytics,
|
||||
'recordDocView'
|
||||
).resolves(),
|
||||
};
|
||||
},
|
||||
request: (app, docId) =>
|
||||
app
|
||||
.GET(`/workspace/${workspace.id}/${docId}`)
|
||||
.set('accept', 'text/markdown'),
|
||||
assert: (t, res, stubs, docId) => {
|
||||
t.true(stubs.markdown?.calledOnceWithExactly(workspace.id, docId, false));
|
||||
t.is(res.text, '# markdown-doc');
|
||||
t.true(
|
||||
(res.headers['content-type'] as string).startsWith('text/markdown')
|
||||
);
|
||||
t.true(stubs.docContent?.notCalled);
|
||||
t.true(stubs.record?.notCalled);
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'should not return markdown for private page even if workspace preview is enabled',
|
||||
content: 'private markdown',
|
||||
expectedStatus: 404,
|
||||
setup: async (models, _docId, docReader) => {
|
||||
await models.workspace.update(workspace.id, {
|
||||
enableUrlPreview: true,
|
||||
});
|
||||
return {
|
||||
markdown: Sinon.stub(docReader, 'getDocMarkdown'),
|
||||
};
|
||||
},
|
||||
request: (app, docId) =>
|
||||
app
|
||||
.GET(`/workspace/${workspace.id}/${docId}`)
|
||||
.set('accept', 'text/markdown'),
|
||||
assert: (t, _res, stubs) => {
|
||||
t.true(stubs.markdown?.notCalled);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'should not render shared page when workspace sharing is disabled',
|
||||
content: 'shared but disabled',
|
||||
expectedStatus: 200,
|
||||
setup: async (models, docId, docReader) => {
|
||||
await models.doc.publish(workspace.id, docId);
|
||||
await models.workspace.update(workspace.id, {
|
||||
enableSharing: false,
|
||||
enableUrlPreview: true,
|
||||
});
|
||||
return {
|
||||
docContent: Sinon.stub(docReader, 'getDocContent'),
|
||||
};
|
||||
},
|
||||
request: (app, docId) => app.GET(`/workspace/${workspace.id}/${docId}`),
|
||||
assert: (t, res, stubs) => {
|
||||
t.true(stubs.docContent?.notCalled);
|
||||
t.is(res.headers['x-robots-tag'], 'noindex');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const doc = new YDoc();
|
||||
const text = doc.getText('content');
|
||||
const updates: Buffer[] = [];
|
||||
for (const policyCase of policyCases) {
|
||||
test(policyCase.title, async t => {
|
||||
const { app, adapter, models, docReader } = t.context;
|
||||
const docId = await createDoc(adapter, policyCase.content);
|
||||
const stubs = await policyCase.setup(models, docId, docReader);
|
||||
|
||||
doc.on('update', update => {
|
||||
updates.push(Buffer.from(update));
|
||||
try {
|
||||
const res = await policyCase
|
||||
.request(app, docId)
|
||||
.expect(policyCase.expectedStatus);
|
||||
policyCase.assert(t, res, stubs, docId);
|
||||
} finally {
|
||||
stubs.markdown?.restore();
|
||||
stubs.docContent?.restore();
|
||||
stubs.record?.restore();
|
||||
}
|
||||
});
|
||||
|
||||
text.insert(0, 'markdown');
|
||||
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
|
||||
await models.doc.publish(workspace.id, docId);
|
||||
|
||||
const markdown = Sinon.stub(docReader, 'getDocMarkdown').resolves({
|
||||
title: 'markdown-doc',
|
||||
markdown: '# markdown-doc',
|
||||
knownUnsupportedBlocks: [],
|
||||
unknownBlocks: [],
|
||||
});
|
||||
const docContent = Sinon.stub(docReader, 'getDocContent');
|
||||
const record = Sinon.stub(
|
||||
models.workspaceAnalytics,
|
||||
'recordDocView'
|
||||
).resolves();
|
||||
|
||||
const res = await app
|
||||
.GET(`/workspace/${workspace.id}/${docId}`)
|
||||
.set('accept', 'text/markdown')
|
||||
.expect(200);
|
||||
|
||||
t.true(markdown.calledOnceWithExactly(workspace.id, docId, false));
|
||||
t.is(res.text, '# markdown-doc');
|
||||
t.true((res.headers['content-type'] as string).startsWith('text/markdown'));
|
||||
t.true(docContent.notCalled);
|
||||
t.true(record.notCalled);
|
||||
|
||||
markdown.restore();
|
||||
docContent.restore();
|
||||
record.restore();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Models } from '../../models';
|
||||
import { htmlSanitize } from '../../native';
|
||||
import { Public } from '../auth';
|
||||
import { DocReader } from '../doc';
|
||||
import { WorkspacePolicyService } from '../permission';
|
||||
|
||||
interface RenderOptions {
|
||||
title: string;
|
||||
@@ -59,7 +60,8 @@ export class DocRendererController {
|
||||
constructor(
|
||||
private readonly doc: DocReader,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config
|
||||
private readonly config: Config,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
) {
|
||||
this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static'));
|
||||
this.mobileAssets = this.readHtmlAssets(
|
||||
@@ -74,21 +76,6 @@ export class DocRendererController {
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private async allowDocPreview(workspaceId: string, docId: string) {
|
||||
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
|
||||
if (!allowSharing) return false;
|
||||
|
||||
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
|
||||
|
||||
if (!allowUrlPreview) {
|
||||
// if page is private, but workspace url preview is on
|
||||
allowUrlPreview =
|
||||
await this.models.workspace.allowUrlPreview(workspaceId);
|
||||
}
|
||||
|
||||
return allowUrlPreview;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/*path')
|
||||
async render(@Req() req: Request, @Res() res: Response) {
|
||||
@@ -112,8 +99,11 @@ export class DocRendererController {
|
||||
req.accepts().some(t => markdownType.has(t.toLowerCase()))
|
||||
) {
|
||||
try {
|
||||
const allowPreview = await this.allowDocPreview(workspaceId, sub);
|
||||
if (!allowPreview) {
|
||||
const canReadMarkdown = await this.policy.canReadSharedDoc(
|
||||
workspaceId,
|
||||
sub
|
||||
);
|
||||
if (!canReadMarkdown) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
@@ -172,7 +162,7 @@ export class DocRendererController {
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
if (await this.allowDocPreview(workspaceId, docId)) {
|
||||
if (await this.policy.canPreviewDoc(workspaceId, docId)) {
|
||||
return this.doc.getDocContent(workspaceId, docId);
|
||||
}
|
||||
|
||||
@@ -182,24 +172,18 @@ export class DocRendererController {
|
||||
private async getWorkspaceContent(
|
||||
workspaceId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
|
||||
if (!allowSharing) {
|
||||
return null;
|
||||
}
|
||||
const canPreviewWorkspace =
|
||||
await this.policy.canPreviewWorkspace(workspaceId);
|
||||
if (!canPreviewWorkspace) return null;
|
||||
|
||||
const allowUrlPreview =
|
||||
await this.models.workspace.allowUrlPreview(workspaceId);
|
||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||
|
||||
if (allowUrlPreview) {
|
||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||
|
||||
if (workspaceContent) {
|
||||
return {
|
||||
title: workspaceContent.name,
|
||||
summary: '',
|
||||
avatar: workspaceContent.avatarUrl,
|
||||
};
|
||||
}
|
||||
if (workspaceContent) {
|
||||
return {
|
||||
title: workspaceContent.name,
|
||||
summary: '',
|
||||
avatar: workspaceContent.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -21,6 +21,7 @@ let ac: DocAccessController;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
let underReviewUserId: string;
|
||||
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
@@ -39,111 +40,145 @@ test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should get null role', async t => {
|
||||
const role = await ac.getRole({
|
||||
workspaceId: 'ws1',
|
||||
docId: 'doc1',
|
||||
userId: 'u1',
|
||||
const roleCases: Array<{
|
||||
title: string;
|
||||
setup?: () => Promise<void>;
|
||||
resource: () => {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
};
|
||||
expectedRole: DocRole | null;
|
||||
}> = [
|
||||
{
|
||||
title: 'should get null role',
|
||||
resource: () => ({
|
||||
workspaceId: 'ws1',
|
||||
docId: 'doc1',
|
||||
userId: 'u1',
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title: 'should return null if workspace role is not accepted',
|
||||
setup: async () => {
|
||||
const u2 = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
underReviewUserId = u2.id;
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: underReviewUserId,
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title:
|
||||
'should return [Owner] role if workspace is not found but local is allowed',
|
||||
resource: () => ({
|
||||
workspaceId: 'ws1',
|
||||
docId: 'doc1',
|
||||
userId: 'u1',
|
||||
allowLocal: true,
|
||||
}),
|
||||
expectedRole: DocRole.Owner,
|
||||
},
|
||||
{
|
||||
title: 'should fallback to [External] if workspace is public',
|
||||
setup: async () => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
});
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: DocRole.External,
|
||||
},
|
||||
{
|
||||
title: 'should return null even if workspace has other public doc',
|
||||
setup: async () => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc2',
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title: 'should return [External] if doc is public',
|
||||
setup: async () => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: DocRole.External,
|
||||
},
|
||||
{
|
||||
title: 'should return null if doc role is [None]',
|
||||
setup: async () => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
user.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title: 'should return [External] if doc role is [None] but doc is public',
|
||||
setup: async () => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
user.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: DocRole.External,
|
||||
},
|
||||
];
|
||||
|
||||
for (const roleCase of roleCases) {
|
||||
test(roleCase.title, async t => {
|
||||
await roleCase.setup?.();
|
||||
const resource = roleCase.resource();
|
||||
const role = await ac.getRole(resource);
|
||||
|
||||
t.is(role, roleCase.expectedRole);
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return null if workspace role is not accepted', async t => {
|
||||
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: u2.id,
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return [Owner] role if workspace is not found but local is allowed', async t => {
|
||||
const role = await ac.getRole({
|
||||
workspaceId: 'ws1',
|
||||
docId: 'doc1',
|
||||
userId: 'u1',
|
||||
allowLocal: true,
|
||||
});
|
||||
|
||||
t.is(role, DocRole.Owner);
|
||||
});
|
||||
|
||||
test('should fallback to [External] if workspace is public', async t => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
});
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, DocRole.External);
|
||||
});
|
||||
|
||||
test('should return null even if workspace has other public doc', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc2',
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return [External] if doc is public', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, DocRole.External);
|
||||
});
|
||||
|
||||
test('should return null if doc role is [None]', async t => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
|
||||
await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return [External] if doc role is [None] but doc is public', async t => {
|
||||
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
|
||||
await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, DocRole.External);
|
||||
});
|
||||
}
|
||||
|
||||
test('should return mapped permissions', async t => {
|
||||
const { permissions } = await ac.role({
|
||||
@@ -155,6 +190,66 @@ test('should return mapped permissions', async t => {
|
||||
t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner));
|
||||
});
|
||||
|
||||
test('should deny publish permission when workspace sharing is disabled', async t => {
|
||||
await models.workspace.update(ws.id, {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
t.false(permissions['Doc.Publish']);
|
||||
t.true(permissions['Doc.Read']);
|
||||
});
|
||||
|
||||
test('should deny publish assert when workspace sharing is disabled', async t => {
|
||||
await models.workspace.update(ws.id, {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Publish'
|
||||
)
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
},
|
||||
'Doc.Read'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('should deny external read assert when sharing is disabled even if doc is public', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
await models.workspace.update(ws.id, {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Doc.Read'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('should assert action', async t => {
|
||||
await t.notThrowsAsync(
|
||||
ac.assert(
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { SpaceAccessDenied } from '../../../base';
|
||||
import {
|
||||
DocActionDenied,
|
||||
OwnerCanNotLeaveWorkspace,
|
||||
SpaceAccessDenied,
|
||||
} from '../../../base';
|
||||
import {
|
||||
Models,
|
||||
User,
|
||||
@@ -145,6 +149,49 @@ test('should deny blob uploads when user no longer has write access', async t =>
|
||||
);
|
||||
});
|
||||
|
||||
test('should deny publish through policy when workspace sharing is disabled', async t => {
|
||||
await t.context.models.workspace.update(workspace.id, {
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanPublishDoc(owner.id, workspace.id, 'doc1'),
|
||||
{ instanceOf: DocActionDenied }
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanUnpublishDoc(owner.id, workspace.id, 'doc1')
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow managers to revoke invite links in readonly workspace', async t => {
|
||||
await addAcceptedMembers(t.context.models, workspace.id, 10);
|
||||
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
|
||||
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanManageInviteLink(owner.id, workspace.id)
|
||||
);
|
||||
});
|
||||
|
||||
test('should apply leave workspace policy by role', async t => {
|
||||
const collaborator = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
collaborator.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{ status: WorkspaceMemberStatus.Accepted }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanLeaveWorkspace(owner.id, workspace.id),
|
||||
{ instanceOf: OwnerCanNotLeaveWorkspace }
|
||||
);
|
||||
await t.notThrowsAsync(
|
||||
t.context.policy.assertCanLeaveWorkspace(collaborator.id, workspace.id)
|
||||
);
|
||||
});
|
||||
|
||||
test('should enter readonly mode when fallback owner storage quota overflows', async t => {
|
||||
const quota = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quota') as QuotaService,
|
||||
|
||||
@@ -21,6 +21,7 @@ let ac: WorkspaceAccessController;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
let underReviewUserId: string;
|
||||
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
@@ -39,90 +40,114 @@ test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should get null role', async t => {
|
||||
const role = await ac.getRole({
|
||||
workspaceId: 'ws1',
|
||||
userId: 'u1',
|
||||
const roleCases: Array<{
|
||||
title: string;
|
||||
setup?: () => Promise<void>;
|
||||
resource: () => {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
allowLocal?: boolean;
|
||||
};
|
||||
expectedRole: WorkspaceRole | null;
|
||||
}> = [
|
||||
{
|
||||
title: 'should get null role',
|
||||
resource: () => ({
|
||||
workspaceId: 'ws1',
|
||||
userId: 'u1',
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title: 'should return null if role is not accepted',
|
||||
setup: async () => {
|
||||
const u2 = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
underReviewUserId = u2.id;
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
userId: underReviewUserId,
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title:
|
||||
'should return [Owner] role if workspace is not found but local is allowed',
|
||||
resource: () => ({
|
||||
workspaceId: 'ws1',
|
||||
userId: 'u1',
|
||||
allowLocal: true,
|
||||
}),
|
||||
expectedRole: WorkspaceRole.Owner,
|
||||
},
|
||||
{
|
||||
title: 'should fallback to [External] if workspace is public',
|
||||
setup: async () => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
});
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: WorkspaceRole.External,
|
||||
},
|
||||
{
|
||||
title: 'should return null if workspace is public but sharing disabled',
|
||||
setup: async () => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
enableSharing: false,
|
||||
});
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title: 'should return null even workspace has public doc',
|
||||
setup: async () => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
{
|
||||
title:
|
||||
'should return null even workspace has public doc when sharing disabled',
|
||||
setup: async () => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
await models.workspace.update(ws.id, { enableSharing: false });
|
||||
},
|
||||
resource: () => ({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
}),
|
||||
expectedRole: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const roleCase of roleCases) {
|
||||
test(roleCase.title, async t => {
|
||||
await roleCase.setup?.();
|
||||
const role = await ac.getRole(roleCase.resource());
|
||||
|
||||
t.is(role, roleCase.expectedRole);
|
||||
});
|
||||
}
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return null if role is not accepted', async t => {
|
||||
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
userId: u2.id,
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return [Owner] role if workspace is not found but local is allowed', async t => {
|
||||
const role = await ac.getRole({
|
||||
workspaceId: 'ws1',
|
||||
userId: 'u1',
|
||||
allowLocal: true,
|
||||
});
|
||||
|
||||
t.is(role, WorkspaceRole.Owner);
|
||||
});
|
||||
|
||||
test('should fallback to [External] if workspace is public', async t => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
});
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, WorkspaceRole.External);
|
||||
});
|
||||
|
||||
test('should return null if workspace is public but sharing disabled', async t => {
|
||||
await models.workspace.update(ws.id, {
|
||||
public: true,
|
||||
enableSharing: false,
|
||||
});
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return null even workspace has public doc', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return null even workspace has public doc when sharing disabled', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
await models.workspace.update(ws.id, { enableSharing: false });
|
||||
|
||||
const role = await ac.getRole({
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should return mapped external permission for workspace has public docs', async t => {
|
||||
test('should return mapped null permission even workspace has public docs', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
@@ -130,9 +155,35 @@ test('should return mapped external permission for workspace has public docs', a
|
||||
userId: 'random-user-id',
|
||||
});
|
||||
|
||||
t.deepEqual(
|
||||
permissions,
|
||||
mapWorkspaceRoleToPermissions(WorkspaceRole.External)
|
||||
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(null));
|
||||
});
|
||||
|
||||
test('should deny external read assert even workspace has public docs', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Workspace.Read'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('should deny external read assert when sharing disabled even if workspace has public docs', async t => {
|
||||
await models.doc.publish(ws.id, 'doc1');
|
||||
await models.workspace.update(ws.id, { enableSharing: false });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
{
|
||||
workspaceId: ws.id,
|
||||
userId: 'random-user-id',
|
||||
},
|
||||
'Workspace.Read'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DocActionDenied } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { AccessController, getAccessController } from './controller';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import type { Resource } from './resource';
|
||||
@@ -16,10 +15,7 @@ import { WorkspaceAccessController } from './workspace';
|
||||
@Injectable()
|
||||
export class DocAccessController extends AccessController<'doc'> {
|
||||
protected readonly type = 'doc';
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
) {
|
||||
constructor(private readonly policy: WorkspacePolicyService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -29,7 +25,7 @@ export class DocAccessController extends AccessController<'doc'> {
|
||||
resource.workspaceId,
|
||||
mapDocRoleToPermissions(role)
|
||||
);
|
||||
const sharingAllowed = await this.models.workspace.allowSharing(
|
||||
const sharingAllowed = await this.policy.canPublishDoc(
|
||||
resource.workspaceId
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
|
||||
import { DocActionDenied, OnEvent, SpaceAccessDenied } from '../../base';
|
||||
import {
|
||||
DocActionDenied,
|
||||
OnEvent,
|
||||
OwnerCanNotLeaveWorkspace,
|
||||
SpaceAccessDenied,
|
||||
} from '../../base';
|
||||
import { Models, WorkspaceRole } from '../../models';
|
||||
import { QuotaService } from '../quota/service';
|
||||
import { getAccessController } from './controller';
|
||||
@@ -164,6 +169,57 @@ export class WorkspacePolicyService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async isSharingEnabled(workspaceId: string) {
|
||||
return await this.models.workspace.allowSharing(workspaceId);
|
||||
}
|
||||
|
||||
async canReadWorkspaceByPublicFlag(workspaceId: string) {
|
||||
const workspace = await this.models.workspace.get(workspaceId);
|
||||
return !!workspace?.public && (workspace.enableSharing ?? true);
|
||||
}
|
||||
|
||||
async canReadWorkspaceBySharedDocs(workspaceId: string) {
|
||||
const [sharingEnabled, hasPublicDocs] = await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.models.doc.hasPublic(workspaceId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && hasPublicDocs;
|
||||
}
|
||||
|
||||
async canReadSharedDoc(workspaceId: string, docId: string) {
|
||||
const [sharingEnabled, isPublicDoc] = await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.models.doc.isPublic(workspaceId, docId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && isPublicDoc;
|
||||
}
|
||||
|
||||
async canPreviewDoc(workspaceId: string, docId: string) {
|
||||
const [sharingEnabled, canReadSharedDoc, allowUrlPreview] =
|
||||
await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.canReadSharedDoc(workspaceId, docId),
|
||||
this.models.workspace.allowUrlPreview(workspaceId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && (canReadSharedDoc || allowUrlPreview);
|
||||
}
|
||||
|
||||
async canPreviewWorkspace(workspaceId: string) {
|
||||
const [sharingEnabled, allowUrlPreview] = await Promise.all([
|
||||
this.isSharingEnabled(workspaceId),
|
||||
this.models.workspace.allowUrlPreview(workspaceId),
|
||||
]);
|
||||
|
||||
return sharingEnabled && allowUrlPreview;
|
||||
}
|
||||
|
||||
async canPublishDoc(workspaceId: string) {
|
||||
return await this.isSharingEnabled(workspaceId);
|
||||
}
|
||||
|
||||
async applyWorkspacePermissions(
|
||||
workspaceId: string,
|
||||
permissions: WorkspaceActionPermissions
|
||||
@@ -299,10 +355,6 @@ export class WorkspacePolicyService {
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanPublishDoc(workspaceId: string, docId: string) {
|
||||
await this.assertDocActionAllowed(workspaceId, docId, 'Doc.Publish');
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async handleTeamPlanCanceled(workspaceId: string) {
|
||||
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
|
||||
@@ -319,6 +371,43 @@ export class WorkspacePolicyService {
|
||||
await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish');
|
||||
}
|
||||
|
||||
async assertCanPublishDoc(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
) {
|
||||
await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish');
|
||||
await this.assertDocActionAllowed(workspaceId, docId, 'Doc.Publish');
|
||||
|
||||
if (!(await this.canPublishDoc(workspaceId))) {
|
||||
throw new DocActionDenied({
|
||||
action: 'Doc.Publish',
|
||||
docId,
|
||||
spaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanManageInviteLink(userId: string, workspaceId: string) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
'Workspace.Users.Manage'
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanLeaveWorkspace(userId: string, workspaceId: string) {
|
||||
const role = await this.models.workspaceUser.getActive(workspaceId, userId);
|
||||
|
||||
if (!role) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
|
||||
if (role.type === WorkspaceRole.Owner) {
|
||||
throw new OwnerCanNotLeaveWorkspace();
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated')
|
||||
async onWorkspaceMembersUpdated({
|
||||
workspaceId,
|
||||
|
||||
@@ -26,18 +26,7 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
}
|
||||
|
||||
async role(resource: Resource<'ws'>) {
|
||||
let role = await this.getRole(resource);
|
||||
|
||||
// NOTE(@forehalo): special case for public page
|
||||
// Currently, we can not only load binary of a public Doc to render in a shared page,
|
||||
// so we need to ensure anyone has basic 'read' permission to a workspace that has public pages.
|
||||
if (
|
||||
!role &&
|
||||
(await this.models.workspace.allowSharing(resource.workspaceId)) &&
|
||||
(await this.models.doc.hasPublic(resource.workspaceId))
|
||||
) {
|
||||
role = WorkspaceRole.External;
|
||||
}
|
||||
const role = await this.getRole(resource);
|
||||
|
||||
return {
|
||||
role,
|
||||
@@ -103,7 +92,7 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
}
|
||||
|
||||
const workspaceRole = await this.getRole(payload);
|
||||
const sharingAllowed = await this.models.workspace.allowSharing(
|
||||
const sharingAllowed = await this.policy.isSharingEnabled(
|
||||
payload.workspaceId
|
||||
);
|
||||
if (
|
||||
@@ -210,7 +199,9 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
}
|
||||
|
||||
if (ws.public) {
|
||||
const sharingAllowed = await this.models.workspace.allowSharing(ws.id);
|
||||
const sharingAllowed = await this.policy.canReadWorkspaceByPublicFlag(
|
||||
ws.id
|
||||
);
|
||||
return sharingAllowed ? WorkspaceRole.External : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
PgWorkspaceDocStorageAdapter,
|
||||
} from '../doc';
|
||||
import { applyUpdatesWithNative } from '../doc/merge-updates';
|
||||
import { AccessController, WorkspaceAction } from '../permission';
|
||||
import {
|
||||
AccessController,
|
||||
type DocAction,
|
||||
WorkspaceAction,
|
||||
} from '../permission';
|
||||
import { DocID } from '../utils/doc';
|
||||
|
||||
const SubscribeMessage = (event: string) =>
|
||||
@@ -304,6 +308,23 @@ export class SpaceSyncGateway
|
||||
setImmediate(() => client.disconnect());
|
||||
}
|
||||
|
||||
private async assertDocActionAllowed(
|
||||
spaceType: SpaceType,
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
docId: string,
|
||||
action: DocAction
|
||||
) {
|
||||
if (spaceType === SpaceType.Userspace) {
|
||||
if (spaceId !== userId) {
|
||||
throw new SpaceAccessDenied({ spaceId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ac.user(userId).doc(spaceId, docId).assert(action);
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.connectionCount++;
|
||||
this.logger.debug(`New connection, total: ${this.connectionCount}`);
|
||||
@@ -607,9 +628,17 @@ export class SpaceSyncGateway
|
||||
@SubscribeMessage('space:delete-doc')
|
||||
async onDeleteSpaceDoc(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody() { spaceType, spaceId, docId }: DeleteDocMessage
|
||||
) {
|
||||
const adapter = this.selectAdapter(client, spaceType);
|
||||
await this.assertDocActionAllowed(
|
||||
spaceType,
|
||||
user.id,
|
||||
spaceId,
|
||||
docId,
|
||||
'Doc.Delete'
|
||||
);
|
||||
await adapter.delete(spaceId, docId);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Head,
|
||||
Logger,
|
||||
Param,
|
||||
Query,
|
||||
@@ -16,16 +17,19 @@ import {
|
||||
BlobNotFound,
|
||||
CallMetric,
|
||||
CommentAttachmentNotFound,
|
||||
DocActionDenied,
|
||||
DocHistoryNotFound,
|
||||
DocNotFound,
|
||||
getRequestTrackerId,
|
||||
InvalidHistoryTimestamp,
|
||||
SpaceAccessDenied,
|
||||
} from '../../base';
|
||||
import { DocMode, Models, PublicDocMode } from '../../models';
|
||||
import { buildPublicRootDoc } from '../../native';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../doc';
|
||||
import { DocReader } from '../doc/reader';
|
||||
import { AccessController } from '../permission';
|
||||
import { AccessController, WorkspacePolicyService } from '../permission';
|
||||
import { CommentAttachmentStorage, WorkspaceBlobStorage } from '../storage';
|
||||
import { DocID } from '../utils/doc';
|
||||
|
||||
@@ -36,6 +40,7 @@ export class WorkspacesController {
|
||||
private readonly storage: WorkspaceBlobStorage,
|
||||
private readonly commentAttachmentStorage: CommentAttachmentStorage,
|
||||
private readonly ac: AccessController,
|
||||
private readonly workspacePolicy: WorkspacePolicyService,
|
||||
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
||||
private readonly docReader: DocReader,
|
||||
private readonly models: Models
|
||||
@@ -48,6 +53,48 @@ export class WorkspacesController {
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private async assertCanReadPublicDoc(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
) {
|
||||
const canReadSharedDoc = await this.ac
|
||||
.user(userId)
|
||||
.doc(workspaceId, docId)
|
||||
.can('Doc.Read');
|
||||
if (!canReadSharedDoc) {
|
||||
throw new DocActionDenied({
|
||||
docId,
|
||||
spaceId: workspaceId,
|
||||
action: 'Doc.Read',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getPublishModeHeader(workspaceId: string, docId: string) {
|
||||
const docMeta = await this.models.doc.getMeta(workspaceId, docId, {
|
||||
select: {
|
||||
mode: true,
|
||||
},
|
||||
});
|
||||
return docMeta?.mode === PublicDocMode.Edgeless
|
||||
? DocMode.edgeless
|
||||
: DocMode.page;
|
||||
}
|
||||
|
||||
private async getDocBinaryOrThrow(workspaceId: string, docId: string) {
|
||||
const binResponse = await this.docReader.getDoc(workspaceId, docId);
|
||||
|
||||
if (!binResponse) {
|
||||
throw new DocNotFound({
|
||||
spaceId: workspaceId,
|
||||
docId,
|
||||
});
|
||||
}
|
||||
|
||||
return binResponse;
|
||||
}
|
||||
|
||||
// get workspace blob
|
||||
//
|
||||
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
||||
@@ -61,10 +108,15 @@ export class WorkspacesController {
|
||||
@Query('redirect') redirect: string | undefined,
|
||||
@Res() res: Response
|
||||
) {
|
||||
await this.ac
|
||||
const canReadWorkspace = await this.ac
|
||||
.user(user?.id ?? 'anonymous')
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Read');
|
||||
.can('Workspace.Read');
|
||||
const canReadSharedWorkspaceBlobs =
|
||||
await this.workspacePolicy.canReadWorkspaceBySharedDocs(workspaceId);
|
||||
if (!canReadWorkspace && !canReadSharedWorkspaceBlobs) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
const { body, metadata, redirectUrl } = await this.storage.get(
|
||||
workspaceId,
|
||||
name,
|
||||
@@ -186,6 +238,94 @@ export class WorkspacesController {
|
||||
res.send(binResponse.bin);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Head('/:id/public-docs/:docId')
|
||||
@CallMetric('controllers', 'workspace_head_public_doc')
|
||||
async headPublicDoc(
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@Param('id') workspaceId: string,
|
||||
@Param('docId') docId: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
await this.assertCanReadPublicDoc(
|
||||
user?.id ?? 'anonymous',
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
const publishPageMode = await this.getPublishModeHeader(workspaceId, docId);
|
||||
|
||||
res.setHeader('publish-mode', publishPageMode);
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/:id/public-docs/:docId')
|
||||
@CallMetric('controllers', 'workspace_get_public_doc')
|
||||
async publicDoc(
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@Req() req: Request,
|
||||
@Param('id') workspaceId: string,
|
||||
@Param('docId') docId: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
await this.assertCanReadPublicDoc(
|
||||
user?.id ?? 'anonymous',
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
|
||||
const binResponse = await this.getDocBinaryOrThrow(workspaceId, docId);
|
||||
|
||||
void this.models.workspaceAnalytics
|
||||
.recordDocView({
|
||||
workspaceId,
|
||||
docId,
|
||||
userId: user?.id,
|
||||
visitorId: this.buildVisitorId(req, workspaceId, docId),
|
||||
isGuest: !user,
|
||||
})
|
||||
.catch(error => {
|
||||
this.logger.warn(
|
||||
`Failed to record doc view: ${workspaceId}/${docId}`,
|
||||
error as Error
|
||||
);
|
||||
});
|
||||
|
||||
res.setHeader(
|
||||
'publish-mode',
|
||||
await this.getPublishModeHeader(workspaceId, docId)
|
||||
);
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.send(binResponse.bin);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/:id/public-docs/:docId/root-doc')
|
||||
@CallMetric('controllers', 'workspace_get_public_root_doc')
|
||||
async publicRootDoc(
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@Param('id') workspaceId: string,
|
||||
@Param('docId') docId: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
await this.assertCanReadPublicDoc(
|
||||
user?.id ?? 'anonymous',
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
|
||||
const rootDoc = await this.getDocBinaryOrThrow(workspaceId, workspaceId);
|
||||
|
||||
const publicDocs = await this.models.doc.findPublics(workspaceId);
|
||||
const publicRootDoc = buildPublicRootDoc(
|
||||
Buffer.from(rootDoc.bin),
|
||||
publicDocs.map(doc => ({ id: doc.docId, title: doc.title ?? undefined }))
|
||||
);
|
||||
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.send(publicRootDoc);
|
||||
}
|
||||
|
||||
@Get('/:id/docs/:guid/histories/:timestamp')
|
||||
@CallMetric('controllers', 'workspace_get_history')
|
||||
async history(
|
||||
|
||||
@@ -307,9 +307,12 @@ export class WorkspaceDocResolver {
|
||||
deprecationReason: 'use [WorkspaceType.doc] instead',
|
||||
})
|
||||
async pageMeta(
|
||||
@CurrentUser() me: CurrentUser,
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
await this.ac.user(me.id).doc(workspace.id, pageId).assert('Doc.Read');
|
||||
|
||||
const metadata = await this.models.doc.getAuthors(workspace.id, pageId);
|
||||
if (!metadata) {
|
||||
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
|
||||
@@ -333,9 +336,15 @@ export class WorkspaceDocResolver {
|
||||
|
||||
@ResolveField(() => PaginatedDocType)
|
||||
async docs(
|
||||
@CurrentUser() me: CurrentUser,
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('pagination', PaginationInput.decode) pagination: PaginationInput
|
||||
): Promise<PaginatedDocType> {
|
||||
await this.ac
|
||||
.user(me.id)
|
||||
.workspace(workspace.id)
|
||||
.assert('Workspace.Users.Manage');
|
||||
|
||||
const [count, rows] = await this.models.doc.paginateDocInfo(
|
||||
workspace.id,
|
||||
pagination
|
||||
@@ -414,7 +423,7 @@ export class WorkspaceDocResolver {
|
||||
throw new ExpectToPublishDoc();
|
||||
}
|
||||
|
||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||
await this.policy.assertCanPublishDoc(user.id, workspaceId, docId);
|
||||
|
||||
const doc = await this.models.doc.publish(workspaceId, docId, mode);
|
||||
|
||||
|
||||
@@ -25,10 +25,8 @@ import {
|
||||
mapAnyError,
|
||||
MemberNotFoundInSpace,
|
||||
NoMoreSeat,
|
||||
OwnerCanNotLeaveWorkspace,
|
||||
QueryTooLong,
|
||||
RequestMutex,
|
||||
SpaceAccessDenied,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
URLHelper,
|
||||
@@ -309,11 +307,7 @@ export class WorkspaceMemberResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.policy.assertWorkspaceRoleAction(
|
||||
user.id,
|
||||
workspaceId,
|
||||
'Workspace.Users.Manage'
|
||||
);
|
||||
await this.policy.assertCanManageInviteLink(user.id, workspaceId);
|
||||
|
||||
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
||||
return await this.cache.delete(cacheId);
|
||||
@@ -560,17 +554,7 @@ export class WorkspaceMemberResolver {
|
||||
})
|
||||
_workspaceName?: string
|
||||
) {
|
||||
const role = await this.models.workspaceUser.getActive(
|
||||
workspaceId,
|
||||
user.id
|
||||
);
|
||||
if (!role) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
|
||||
if (role.type === WorkspaceRole.Owner) {
|
||||
throw new OwnerCanNotLeaveWorkspace();
|
||||
}
|
||||
await this.policy.assertCanLeaveWorkspace(user.id, workspaceId);
|
||||
|
||||
await this.models.workspaceUser.delete(workspaceId, user.id);
|
||||
this.event.emit('workspace.members.leave', {
|
||||
|
||||
@@ -59,6 +59,22 @@ export class CopilotContextModel extends BaseModel {
|
||||
return row;
|
||||
}
|
||||
|
||||
async getAccessInfo(id: string) {
|
||||
return await this.db.aiContext.findFirst({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
sessionId: true,
|
||||
session: {
|
||||
select: {
|
||||
userId: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getConfig(id: string) {
|
||||
const row = await this.get(id);
|
||||
if (row) {
|
||||
|
||||
@@ -55,6 +55,7 @@ export const AFFINE_PRO_LICENSE_AES_KEY =
|
||||
export const createDocWithMarkdown = serverNativeModule.createDocWithMarkdown;
|
||||
export const updateDocWithMarkdown = serverNativeModule.updateDocWithMarkdown;
|
||||
export const addDocToRootDoc = serverNativeModule.addDocToRootDoc;
|
||||
export const buildPublicRootDoc = serverNativeModule.buildPublicRootDoc;
|
||||
export const updateDocTitle = serverNativeModule.updateDocTitle;
|
||||
export const updateDocProperties = serverNativeModule.updateDocProperties;
|
||||
export const updateRootDocMetaTitle = serverNativeModule.updateRootDocMetaTitle;
|
||||
|
||||
@@ -59,6 +59,30 @@ import { CopilotStorage } from '../storage';
|
||||
import { getSignal, MAX_EMBEDDABLE_SIZE, readStream } from '../utils';
|
||||
import { CopilotContextService } from './service';
|
||||
|
||||
async function assertAccess(
|
||||
ac: AccessController,
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
) {
|
||||
await ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
}
|
||||
|
||||
async function getSession(
|
||||
context: CopilotContextService,
|
||||
ac: AccessController,
|
||||
userId: string,
|
||||
contextId: string,
|
||||
options: { workspaceId?: string; sessionId?: string } = {}
|
||||
) {
|
||||
const session = await context.getOwnedContext(userId, contextId, options);
|
||||
await assertAccess(ac, userId, session.workspaceId);
|
||||
return session;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class AddContextCategoryInput {
|
||||
@Field(() => String)
|
||||
@@ -310,8 +334,12 @@ export class CopilotContextRootResolver {
|
||||
}
|
||||
|
||||
if (contextId) {
|
||||
const context = await this.context.get(contextId);
|
||||
if (context) return [context];
|
||||
return [
|
||||
await getSession(this.context, this.ac, user.id, contextId, {
|
||||
sessionId,
|
||||
workspaceId: copilot.workspaceId || undefined,
|
||||
}),
|
||||
];
|
||||
} else if (sessionId) {
|
||||
await this.checkChatSession(
|
||||
user,
|
||||
@@ -515,6 +543,7 @@ export class CopilotContextResolver {
|
||||
})
|
||||
@CallMetric('ai', 'context_category_add')
|
||||
async addContextCategory(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'options', type: () => AddContextCategoryInput })
|
||||
options: AddContextCategoryInput
|
||||
): Promise<CopilotContextCategory> {
|
||||
@@ -523,18 +552,38 @@ export class CopilotContextResolver {
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
const session = await this.context.get(options.contextId);
|
||||
const session = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
options.contextId
|
||||
);
|
||||
|
||||
try {
|
||||
const docs = options.docs?.length
|
||||
? (
|
||||
await Promise.all(
|
||||
options.docs.map(async docId =>
|
||||
(await this.ac
|
||||
.user(user.id)
|
||||
.doc(session.workspaceId, docId)
|
||||
.can('Doc.Read'))
|
||||
? docId
|
||||
: null
|
||||
)
|
||||
)
|
||||
).filter((docId): docId is string => !!docId)
|
||||
: [];
|
||||
|
||||
const records = await session.addCategoryRecord(
|
||||
options.type,
|
||||
options.categoryId,
|
||||
options.docs || []
|
||||
docs
|
||||
);
|
||||
|
||||
if (options.docs) {
|
||||
if (docs.length) {
|
||||
await this.jobs.addDocEmbeddingQueue(
|
||||
options.docs.map(docId => ({
|
||||
docs.map(docId => ({
|
||||
workspaceId: session.workspaceId,
|
||||
docId,
|
||||
})),
|
||||
@@ -556,6 +605,7 @@ export class CopilotContextResolver {
|
||||
})
|
||||
@CallMetric('ai', 'context_category_remove')
|
||||
async removeContextCategory(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'options', type: () => RemoveContextCategoryInput })
|
||||
options: RemoveContextCategoryInput
|
||||
): Promise<boolean> {
|
||||
@@ -564,7 +614,12 @@ export class CopilotContextResolver {
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
const session = await this.context.get(options.contextId);
|
||||
const session = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
options.contextId
|
||||
);
|
||||
|
||||
try {
|
||||
return await session.removeCategoryRecord(
|
||||
@@ -584,6 +639,7 @@ export class CopilotContextResolver {
|
||||
})
|
||||
@CallMetric('ai', 'context_doc_add')
|
||||
async addContextDoc(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'options', type: () => AddContextDocInput })
|
||||
options: AddContextDocInput
|
||||
): Promise<CopilotContextDoc> {
|
||||
@@ -592,9 +648,18 @@ export class CopilotContextResolver {
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
const session = await this.context.get(options.contextId);
|
||||
const session = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
options.contextId
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.doc(session.workspaceId, options.docId)
|
||||
.assert('Doc.Read');
|
||||
const record = await session.addDocRecord(options.docId);
|
||||
|
||||
await this.jobs.addDocEmbeddingQueue(
|
||||
@@ -616,6 +681,7 @@ export class CopilotContextResolver {
|
||||
})
|
||||
@CallMetric('ai', 'context_doc_remove')
|
||||
async removeContextDoc(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'options', type: () => RemoveContextDocInput })
|
||||
options: RemoveContextDocInput
|
||||
): Promise<boolean> {
|
||||
@@ -624,7 +690,12 @@ export class CopilotContextResolver {
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
const session = await this.context.get(options.contextId);
|
||||
const session = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
options.contextId
|
||||
);
|
||||
|
||||
try {
|
||||
return await session.removeDocRecord(options.docId);
|
||||
@@ -664,18 +735,13 @@ export class CopilotContextResolver {
|
||||
throw new BlobQuotaExceeded();
|
||||
}
|
||||
|
||||
const session = await this.context.get(contextId);
|
||||
const session = await getSession(this.context, this.ac, user.id, contextId);
|
||||
|
||||
try {
|
||||
const buffer = await readStream(content.createReadStream());
|
||||
const blobId = createHash('sha256').update(buffer).digest('base64url');
|
||||
const { filename, mimetype } = content;
|
||||
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(session.workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
await this.policy.assertCanUploadBlob(user.id, session.workspaceId);
|
||||
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
|
||||
const file = await session.addFile(
|
||||
@@ -708,6 +774,7 @@ export class CopilotContextResolver {
|
||||
})
|
||||
@CallMetric('ai', 'context_file_remove')
|
||||
async removeContextFile(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'options', type: () => RemoveContextFileInput })
|
||||
options: RemoveContextFileInput
|
||||
): Promise<boolean> {
|
||||
@@ -720,7 +787,12 @@ export class CopilotContextResolver {
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
const session = await this.context.get(options.contextId);
|
||||
const session = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
options.contextId
|
||||
);
|
||||
|
||||
try {
|
||||
return await session.removeFile(options.fileId);
|
||||
@@ -751,13 +823,12 @@ export class CopilotContextResolver {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
|
||||
const contextSession = await this.context.get(options.contextId);
|
||||
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(contextSession.workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
const contextSession = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
options.contextId
|
||||
);
|
||||
|
||||
try {
|
||||
const blob = await contextSession.addBlobRecord(options.blobId);
|
||||
@@ -791,6 +862,7 @@ export class CopilotContextResolver {
|
||||
})
|
||||
@CallMetric('ai', 'context_blob_remove')
|
||||
async removeContextBlob(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'options', type: () => RemoveContextBlobInput })
|
||||
options: RemoveContextBlobInput
|
||||
): Promise<boolean> {
|
||||
@@ -804,7 +876,12 @@ export class CopilotContextResolver {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
|
||||
const contextSession = await this.context.get(options.contextId);
|
||||
const contextSession = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
options.contextId
|
||||
);
|
||||
|
||||
try {
|
||||
return await contextSession.removeBlobRecord(options.blobId);
|
||||
@@ -821,6 +898,7 @@ export class CopilotContextResolver {
|
||||
})
|
||||
@CallMetric('ai', 'context_file_remove')
|
||||
async matchFiles(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Context() ctx: { req: Request },
|
||||
@Parent() context: CopilotContextType,
|
||||
@Args('content') content: string,
|
||||
@@ -837,6 +915,7 @@ export class CopilotContextResolver {
|
||||
|
||||
try {
|
||||
if (!context.id) {
|
||||
await assertAccess(this.ac, user.id, context.workspaceId);
|
||||
return await this.context.matchWorkspaceFiles(
|
||||
context.workspaceId,
|
||||
content,
|
||||
@@ -846,7 +925,13 @@ export class CopilotContextResolver {
|
||||
);
|
||||
}
|
||||
|
||||
const session = await this.context.get(context.id);
|
||||
const session = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
context.id,
|
||||
{ workspaceId: context.workspaceId }
|
||||
);
|
||||
return await session.matchFiles(
|
||||
content,
|
||||
limit,
|
||||
@@ -899,11 +984,7 @@ export class CopilotContextResolver {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(context.workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
await assertAccess(this.ac, user.id, context.workspaceId);
|
||||
const allowEmbedding = await this.models.workspace.allowEmbedding(
|
||||
context.workspaceId
|
||||
);
|
||||
@@ -921,15 +1002,13 @@ export class CopilotContextResolver {
|
||||
);
|
||||
}
|
||||
|
||||
const session = await this.context.get(context.id);
|
||||
if (session.workspaceId !== context.workspaceId) {
|
||||
throw new CopilotFailedToMatchContext({
|
||||
contextId: context.id,
|
||||
// don't record the large content
|
||||
content: content.slice(0, 512),
|
||||
message: 'context not in the same workspace',
|
||||
});
|
||||
}
|
||||
const session = await getSession(
|
||||
this.context,
|
||||
this.ac,
|
||||
user.id,
|
||||
context.id,
|
||||
{ workspaceId: context.workspaceId }
|
||||
);
|
||||
const chunks = await session.matchWorkspaceDocs(
|
||||
content,
|
||||
limit,
|
||||
|
||||
@@ -141,6 +141,26 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
throw new CopilotInvalidContext({ contextId: id });
|
||||
}
|
||||
|
||||
async getOwnedContext(
|
||||
userId: string,
|
||||
contextId: string,
|
||||
options: { workspaceId?: string; sessionId?: string } = {}
|
||||
): Promise<ContextSession> {
|
||||
const accessInfo =
|
||||
await this.models.copilotContext.getAccessInfo(contextId);
|
||||
if (
|
||||
!accessInfo ||
|
||||
accessInfo.session.userId !== userId ||
|
||||
(options.workspaceId &&
|
||||
accessInfo.session.workspaceId !== options.workspaceId) ||
|
||||
(options.sessionId && accessInfo.sessionId !== options.sessionId)
|
||||
) {
|
||||
throw new CopilotInvalidContext({ contextId });
|
||||
}
|
||||
|
||||
return await this.get(contextId);
|
||||
}
|
||||
|
||||
async getBySessionId(sessionId: string): Promise<ContextSession | null> {
|
||||
const existsContext =
|
||||
await this.models.copilotContext.getBySessionId(sessionId);
|
||||
|
||||
@@ -24,10 +24,7 @@ import {
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import {
|
||||
AccessController,
|
||||
WorkspacePolicyService,
|
||||
} from '../../../core/permission';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { WorkspaceType } from '../../../core/workspaces';
|
||||
import { COPILOT_LOCKER } from '../resolver';
|
||||
import { MAX_EMBEDDABLE_SIZE } from '../utils';
|
||||
@@ -75,7 +72,6 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly copilotWorkspace: CopilotWorkspaceService
|
||||
) {}
|
||||
|
||||
@@ -219,11 +215,10 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
|
||||
@Args('fileId', { type: () => String })
|
||||
fileId: string
|
||||
): Promise<boolean> {
|
||||
await this.policy.assertWorkspaceRoleAction(
|
||||
user.id,
|
||||
workspaceId,
|
||||
'Workspace.Settings.Update'
|
||||
);
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Settings.Update');
|
||||
|
||||
return await this.copilotWorkspace.removeFile(workspaceId, fileId);
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ type CopilotWorkspaceIgnoredDocTypeEdge {
|
||||
}
|
||||
|
||||
input CreateChatMessageInput {
|
||||
attachments: [String!]
|
||||
attachments: [String!] @deprecated(reason: "use blobs")
|
||||
blob: Upload
|
||||
blobs: [Upload!]
|
||||
content: String
|
||||
@@ -718,7 +718,7 @@ input CreateChatSessionInput {
|
||||
input CreateCheckoutSessionInput {
|
||||
args: JSONObject
|
||||
coupon: String
|
||||
idempotencyKey: String
|
||||
idempotencyKey: String @deprecated(reason: "not required anymore")
|
||||
plan: SubscriptionPlan = Pro
|
||||
recurring: SubscriptionRecurring = Yearly
|
||||
successCallbackLink: String!
|
||||
|
||||
@@ -832,6 +832,7 @@ export interface CopilotWorkspaceIgnoredDocTypeEdge {
|
||||
}
|
||||
|
||||
export interface CreateChatMessageInput {
|
||||
/** @deprecated use blobs */
|
||||
attachments?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
blob?: InputMaybe<Scalars['Upload']['input']>;
|
||||
blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
|
||||
@@ -853,6 +854,7 @@ export interface CreateChatSessionInput {
|
||||
export interface CreateCheckoutSessionInput {
|
||||
args?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
coupon?: InputMaybe<Scalars['String']['input']>;
|
||||
/** @deprecated not required anymore */
|
||||
idempotencyKey?: InputMaybe<Scalars['String']['input']>;
|
||||
plan?: InputMaybe<SubscriptionPlan>;
|
||||
recurring?: InputMaybe<SubscriptionRecurring>;
|
||||
|
||||
@@ -17,5 +17,6 @@ pub use read::{
|
||||
parse_doc_from_binary, parse_doc_to_markdown, parse_page_doc, parse_workspace_doc,
|
||||
};
|
||||
pub use write::{
|
||||
add_doc_to_root_doc, build_full_doc, update_doc, update_doc_properties, update_doc_title, update_root_doc_meta_title,
|
||||
add_doc_to_root_doc, build_full_doc, build_public_root_doc, update_doc, update_doc_properties, update_doc_title,
|
||||
update_root_doc_meta_title,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ mod update;
|
||||
pub use create::build_full_doc;
|
||||
pub use doc_meta::{update_doc_title, update_root_doc_meta_title};
|
||||
pub use doc_properties::update_doc_properties;
|
||||
pub use root_doc::add_doc_to_root_doc;
|
||||
pub use root_doc::{add_doc_to_root_doc, build_public_root_doc};
|
||||
pub use update::update_doc;
|
||||
use y_octo::{Any, Doc, Map, Value};
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use y_octo::Array;
|
||||
|
||||
use super::*;
|
||||
|
||||
const DEFAULT_DOC_TITLE: &str = "Untitled";
|
||||
const SIMPLE_PAGE_META_KEYS: &[&str] = &[
|
||||
"id",
|
||||
"title",
|
||||
"createDate",
|
||||
"updatedDate",
|
||||
"trash",
|
||||
"trashDate",
|
||||
"headerImage",
|
||||
];
|
||||
|
||||
fn any_to_value(doc: &Doc, any: Any) -> Result<Value, ParseError> {
|
||||
match any {
|
||||
@@ -104,3 +115,177 @@ pub fn add_doc_to_root_doc(root_doc_bin: Vec<u8>, doc_id: &str, title: Option<&s
|
||||
// Encode only the changes (delta) since state_before
|
||||
Ok(doc.encode_state_as_update_v1(&state_before)?)
|
||||
}
|
||||
|
||||
fn insert_page_stub(doc: &Doc, pages: &mut Array, doc_id: &str, title: Option<&str>) -> Result<(), ParseError> {
|
||||
let page_map = doc.create_map()?;
|
||||
let idx = pages.len();
|
||||
pages.insert(idx, Value::Map(page_map))?;
|
||||
|
||||
if let Some(mut inserted_page) = pages.get(idx).and_then(|v| v.to_map()) {
|
||||
inserted_page.insert("id".to_string(), Any::String(doc_id.to_string()))?;
|
||||
inserted_page.insert(
|
||||
"title".to_string(),
|
||||
Any::String(title.unwrap_or(DEFAULT_DOC_TITLE).to_string()),
|
||||
)?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
inserted_page.insert("createDate".to_string(), Any::Float64((timestamp as f64).into()))?;
|
||||
|
||||
let tags = doc.create_array()?;
|
||||
inserted_page.insert("tags".to_string(), Value::Array(tags))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_simple_array(doc: &Doc, values: &[Any]) -> Result<Value, ParseError> {
|
||||
let mut array = doc.create_array()?;
|
||||
for value in values {
|
||||
match value {
|
||||
Any::Array(_) | Any::Object(_) => continue,
|
||||
_ => array.push(Value::Any(value.clone()))?,
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(array))
|
||||
}
|
||||
|
||||
fn insert_page_from_any(doc: &Doc, pages: &mut Array, page: Any) -> Result<Option<String>, ParseError> {
|
||||
let Any::Object(page) = page else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(page_id) = page.get("id").and_then(|value| match value {
|
||||
Any::String(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let page_map = doc.create_map()?;
|
||||
let idx = pages.len();
|
||||
pages.insert(idx, Value::Map(page_map))?;
|
||||
|
||||
let Some(mut inserted_page) = pages.get(idx).and_then(|v| v.to_map()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for key in SIMPLE_PAGE_META_KEYS {
|
||||
let Some(value) = page.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
Any::Array(values) => {
|
||||
inserted_page.insert((*key).to_string(), insert_simple_array(doc, values)?)?;
|
||||
}
|
||||
Any::Object(_) => continue,
|
||||
_ => {
|
||||
inserted_page.insert((*key).to_string(), Value::Any(value.clone()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Any::Array(tags)) = page.get("tags") {
|
||||
inserted_page.insert("tags".to_string(), insert_simple_array(doc, tags)?)?;
|
||||
}
|
||||
|
||||
Ok(Some(page_id))
|
||||
}
|
||||
|
||||
pub fn build_public_root_doc(root_doc_bin: &[u8], doc_metas: &[(&str, Option<&str>)]) -> Result<Vec<u8>, ParseError> {
|
||||
let source = load_doc_or_new(root_doc_bin)?;
|
||||
let public_doc_ids = doc_metas
|
||||
.iter()
|
||||
.map(|(doc_id, _title)| (*doc_id).to_string())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let doc = Doc::default();
|
||||
let mut meta = doc.get_or_create_map("meta")?;
|
||||
let mut pages = ensure_pages_array(&doc, &mut meta)?;
|
||||
let mut copied = HashSet::new();
|
||||
|
||||
if let Ok(source_meta) = source.get_map("meta") {
|
||||
let source_pages_value = source_meta.get("pages");
|
||||
|
||||
if let Some(source_pages) = source_pages_value.as_ref().and_then(|value| value.to_array()) {
|
||||
for page_val in source_pages.iter() {
|
||||
let Some(page) = page_val.to_map() else {
|
||||
continue;
|
||||
};
|
||||
let Some(page_id) = get_string(&page, "id") else {
|
||||
continue;
|
||||
};
|
||||
if !public_doc_ids.contains(&page_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let page_object = Any::Object(
|
||||
page
|
||||
.iter()
|
||||
.filter_map(|(key, value)| value.to_any().map(|any| (key.to_string(), any)))
|
||||
.collect(),
|
||||
);
|
||||
if let Some(inserted_page_id) = insert_page_from_any(&doc, &mut pages, page_object)? {
|
||||
copied.insert(inserted_page_id);
|
||||
}
|
||||
}
|
||||
} else if let Some(Any::Array(entries)) = source_pages_value.and_then(|value| value.to_any()) {
|
||||
for entry in entries {
|
||||
let Any::Object(page) = entry.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(Any::String(page_id)) = page.get("id") else {
|
||||
continue;
|
||||
};
|
||||
if !public_doc_ids.contains(page_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(inserted_page_id) = insert_page_from_any(&doc, &mut pages, entry)? {
|
||||
copied.insert(inserted_page_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (doc_id, title) in doc_metas {
|
||||
if copied.contains(*doc_id) {
|
||||
continue;
|
||||
}
|
||||
insert_page_stub(&doc, &mut pages, doc_id, *title)?;
|
||||
}
|
||||
|
||||
Ok(doc.encode_update_v1()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::doc_parser::get_doc_ids_from_binary;
|
||||
|
||||
#[test]
|
||||
fn test_build_public_root_doc_filters_private_pages() {
|
||||
let root = add_doc_to_root_doc(Vec::new(), "public-doc", Some("Public")).expect("create public entry");
|
||||
let update = add_doc_to_root_doc(root.clone(), "private-doc", Some("Private")).expect("create private entry");
|
||||
|
||||
let mut merged = load_doc_or_new(&root).expect("load root");
|
||||
merged
|
||||
.apply_update_from_binary_v1(&update)
|
||||
.expect("apply second update");
|
||||
let merged_bin = merged.encode_update_v1().expect("encode merged");
|
||||
|
||||
let public_root = build_public_root_doc(
|
||||
&merged_bin,
|
||||
&[("public-doc", Some("Public")), ("missing-public-doc", Some("Fallback"))],
|
||||
)
|
||||
.expect("build public root");
|
||||
|
||||
let doc_ids = get_doc_ids_from_binary(public_root, false).expect("read public root doc ids");
|
||||
assert_eq!(
|
||||
doc_ids,
|
||||
vec!["public-doc".to_string(), "missing-public-doc".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ import { HttpConnection } from './http';
|
||||
|
||||
interface CloudDocStorageOptions extends DocStorageOptions {
|
||||
serverBaseUrl: string;
|
||||
publicRootDocId?: string;
|
||||
}
|
||||
|
||||
const isShareModePrivateSystemDoc = (docId: string) =>
|
||||
docId.startsWith('db$') || docId.startsWith('userdata$');
|
||||
|
||||
export class StaticCloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
static readonly identifier = 'StaticCloudDocStorage';
|
||||
|
||||
@@ -46,15 +50,26 @@ export class StaticCloudDocStorage extends DocStorageBase<CloudDocStorageOptions
|
||||
docId: string
|
||||
): Promise<DocRecord | null> {
|
||||
try {
|
||||
const arrayBuffer = await this.connection.fetchArrayBuffer(
|
||||
`/api/workspaces/${this.spaceId}/docs/${docId}`,
|
||||
{
|
||||
priority: 'high',
|
||||
headers: {
|
||||
Accept: 'application/octet-stream', // this is necessary for ios native fetch to return arraybuffer
|
||||
},
|
||||
}
|
||||
);
|
||||
if (
|
||||
this.options.publicRootDocId &&
|
||||
docId !== this.spaceId &&
|
||||
isShareModePrivateSystemDoc(docId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path =
|
||||
docId === this.spaceId && this.options.publicRootDocId
|
||||
? `/api/workspaces/${this.spaceId}/public-docs/${this.options.publicRootDocId}/root-doc`
|
||||
: this.options.publicRootDocId
|
||||
? `/api/workspaces/${this.spaceId}/public-docs/${docId}`
|
||||
: `/api/workspaces/${this.spaceId}/docs/${docId}`;
|
||||
const arrayBuffer = await this.connection.fetchArrayBuffer(path, {
|
||||
priority: 'high',
|
||||
headers: {
|
||||
Accept: 'application/octet-stream', // this is necessary for ios native fetch to return arraybuffer
|
||||
},
|
||||
});
|
||||
if (!arrayBuffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { TimeoutError } from 'rxjs';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
fetchSharedPublishMode,
|
||||
getResolvedPublishMode,
|
||||
getSearchWithMode,
|
||||
isSharePagePermissionError,
|
||||
isSharePageTimeoutError,
|
||||
parsePublishMode,
|
||||
} from '../desktop/pages/workspace/share/share-page.utils';
|
||||
|
||||
@@ -56,7 +60,7 @@ describe('fetchSharedPublishMode', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL(
|
||||
'/api/workspaces/workspace-id/docs/doc-id',
|
||||
'/api/workspaces/workspace-id/public-docs/doc-id',
|
||||
'https://app.affine.pro'
|
||||
),
|
||||
expect.objectContaining({ method: 'HEAD' })
|
||||
@@ -95,3 +99,34 @@ describe('getSearchWithMode', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share page error helpers', () => {
|
||||
test('recognizes permission errors only', () => {
|
||||
const permissionError = new UserFriendlyError({
|
||||
status: 403,
|
||||
code: 'DOC_ACTION_DENIED',
|
||||
type: 'DOC_ACTION_DENIED',
|
||||
name: 'DOC_ACTION_DENIED',
|
||||
message: 'forbidden',
|
||||
});
|
||||
|
||||
expect(isSharePagePermissionError(permissionError)).toBe(true);
|
||||
expect(isSharePagePermissionError(new TimeoutError())).toBe(false);
|
||||
expect(isSharePagePermissionError(new Error('x'))).toBe(false);
|
||||
});
|
||||
|
||||
test('recognizes timeout errors only', () => {
|
||||
expect(isSharePageTimeoutError(new TimeoutError())).toBe(true);
|
||||
expect(
|
||||
isSharePageTimeoutError(
|
||||
new UserFriendlyError({
|
||||
status: 403,
|
||||
code: 'DOC_ACTION_DENIED',
|
||||
type: 'DOC_ACTION_DENIED',
|
||||
name: 'DOC_ACTION_DENIED',
|
||||
message: 'forbidden',
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { filter, firstValueFrom, timeout } from 'rxjs';
|
||||
|
||||
import { PageNotFound } from '../../404';
|
||||
import { ShareFooter } from './share-footer';
|
||||
@@ -40,9 +41,24 @@ import * as styles from './share-page.css';
|
||||
import {
|
||||
fetchSharedPublishMode,
|
||||
getResolvedPublishMode,
|
||||
isSharePagePermissionError,
|
||||
isSharePageTimeoutError,
|
||||
} from './share-page.utils';
|
||||
import { useSharedModeQuerySync } from './use-shared-mode-query-sync';
|
||||
|
||||
const waitForSharedDocRecord = async (
|
||||
docsService: DocsService,
|
||||
docId: string
|
||||
): Promise<void> => {
|
||||
if (docsService.list.doc$(docId).value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await firstValueFrom(
|
||||
docsService.list.doc$(docId).pipe(filter(Boolean), timeout(3000))
|
||||
);
|
||||
};
|
||||
|
||||
const useUpdateBasename = (workspace: Workspace | null) => {
|
||||
const location = useLocation();
|
||||
const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
|
||||
@@ -131,6 +147,7 @@ const SharePageInner = ({
|
||||
const [page, setPage] = useState<Doc | null>(null);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [noPermission, setNoPermission] = useState(false);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [fetchedPublishMode, setFetchedPublishMode] = useState<
|
||||
DocMode | null | undefined
|
||||
>(() => (publishMode === undefined ? undefined : null));
|
||||
@@ -198,6 +215,7 @@ const SharePageInner = ({
|
||||
name: 'StaticCloudDocStorage',
|
||||
opts: {
|
||||
id: workspaceId,
|
||||
publicRootDocId: docId,
|
||||
serverBaseUrl: serverService.server.baseUrl,
|
||||
},
|
||||
},
|
||||
@@ -218,7 +236,10 @@ const SharePageInner = ({
|
||||
sharedWorkspace.engine.doc
|
||||
.waitForDocLoaded(sharedWorkspace.id)
|
||||
.then(async () => {
|
||||
const { doc } = sharedWorkspace.scope.get(DocsService).open(docId);
|
||||
const docsService = sharedWorkspace.scope.get(DocsService);
|
||||
await waitForSharedDocRecord(docsService, docId);
|
||||
|
||||
const { doc } = docsService.open(docId);
|
||||
doc.blockSuiteDoc.load();
|
||||
doc.blockSuiteDoc.readonly = true;
|
||||
|
||||
@@ -241,7 +262,17 @@ const SharePageInner = ({
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
setNoPermission(true);
|
||||
if (isSharePagePermissionError(err)) {
|
||||
setNoPermission(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSharePageTimeoutError(err)) {
|
||||
setLoadFailed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadFailed(true);
|
||||
});
|
||||
}, [
|
||||
docId,
|
||||
@@ -308,6 +339,10 @@ const SharePageInner = ({
|
||||
return <PageNotFound noPermission />;
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return <PageNotFound />;
|
||||
}
|
||||
|
||||
if (!workspace || !page || !editor || !currentPublishMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { type DocMode, DocModes } from '@blocksuite/affine/model';
|
||||
import { TimeoutError } from 'rxjs';
|
||||
|
||||
export const getResolvedPublishMode = (
|
||||
queryMode: DocMode | null,
|
||||
@@ -35,7 +37,7 @@ export const fetchSharedPublishMode = async ({
|
||||
signal?: AbortSignal;
|
||||
}): Promise<DocMode | null> => {
|
||||
const url = new URL(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/docs/${encodeURIComponent(docId)}`,
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/public-docs/${encodeURIComponent(docId)}`,
|
||||
serverBaseUrl
|
||||
);
|
||||
const headers = {
|
||||
@@ -71,3 +73,12 @@ export const getSearchWithMode = (search: string, mode: DocMode) => {
|
||||
const nextSearch = searchParams.toString();
|
||||
return nextSearch ? `?${nextSearch}` : '';
|
||||
};
|
||||
|
||||
export const isSharePagePermissionError = (error: unknown) =>
|
||||
error instanceof UserFriendlyError &&
|
||||
(error.isStatus(403) ||
|
||||
error.name === 'DOC_ACTION_DENIED' ||
|
||||
error.name === 'SPACE_ACCESS_DENIED');
|
||||
|
||||
export const isSharePageTimeoutError = (error: unknown) =>
|
||||
error instanceof TimeoutError;
|
||||
|
||||
+7
-2
@@ -1,5 +1,6 @@
|
||||
import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { PublicDocMode } from '@affine/graphql';
|
||||
@@ -19,11 +20,13 @@ import * as styles from './styles.css';
|
||||
|
||||
export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
|
||||
const t = useI18n();
|
||||
const editorService = useService(EditorService);
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
const isRevalidating = useLiveData(
|
||||
shareInfoService.shareInfo.isRevalidating$
|
||||
);
|
||||
const currentMode = useLiveData(editorService.editor.mode$);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
@@ -63,7 +66,9 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
|
||||
}
|
||||
try {
|
||||
// TODO(@JimmFly): remove mode when we have a better way to handle it
|
||||
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
|
||||
await shareInfoService.shareInfo.enableShare(
|
||||
currentMode === 'edgeless' ? PublicDocMode.Edgeless : PublicDocMode.Page
|
||||
);
|
||||
track.$.sharePanel.$.createShareLink();
|
||||
notify.success({
|
||||
title:
|
||||
@@ -84,7 +89,7 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}, [isSharedPage, shareInfoService.shareInfo, t]);
|
||||
}, [currentMode, isSharedPage, shareInfoService.shareInfo, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.rowContainerStyle}>
|
||||
|
||||
Reference in New Issue
Block a user