mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-05 03:25:10 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba78a6bd45 |
@@ -540,11 +540,6 @@
|
||||
"description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`",
|
||||
"default": "localhost"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"description": "Multiple hosts the server will accept requests from.\n@default []",
|
||||
"default": []
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",
|
||||
@@ -565,11 +560,6 @@
|
||||
"type": "boolean",
|
||||
"description": "Only allow users with early access features to access the app\n@default false",
|
||||
"default": false
|
||||
},
|
||||
"allowGuestDemoWorkspace": {
|
||||
"type": "boolean",
|
||||
"description": "Whether allow guest users to create demo workspaces.\n@default true",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -597,11 +587,6 @@
|
||||
"type": "string",
|
||||
"description": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.\n@default \">=0.20.0\"",
|
||||
"default": ">=0.20.0"
|
||||
},
|
||||
"allowGuestDemoWorkspace": {
|
||||
"type": "boolean",
|
||||
"description": "Allow guests to access demo workspace.\n@default true",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -742,11 +727,6 @@
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"providers.morph": {
|
||||
"type": "object",
|
||||
"description": "The config for the morph provider.\n@default {}",
|
||||
"default": {}
|
||||
},
|
||||
"unsplash": {
|
||||
"type": "object",
|
||||
"description": "The config for the unsplash key.\n@default {\"key\":\"\"}",
|
||||
|
||||
@@ -126,10 +126,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
? 'internal'
|
||||
: 'dev';
|
||||
|
||||
const hosts = (DEPLOY_HOST || CANARY_DEPLOY_HOST)
|
||||
.split(',')
|
||||
.map(host => host.trim())
|
||||
.filter(host => host);
|
||||
const host = DEPLOY_HOST || CANARY_DEPLOY_HOST;
|
||||
const deployCommand = [
|
||||
`helm upgrade --install affine .github/helm/affine`,
|
||||
`--namespace ${namespace}`,
|
||||
@@ -138,9 +135,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string global.app.buildType="${buildType}"`,
|
||||
`--set global.ingress.enabled=true`,
|
||||
`--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`,
|
||||
...hosts.map(
|
||||
(host, index) => `--set global.ingress.hosts[${index}]=${host}`
|
||||
),
|
||||
`--set-string global.ingress.host="${host}"`,
|
||||
`--set-string global.version="${APP_VERSION}"`,
|
||||
...redisAndPostgres,
|
||||
...indexerOptions,
|
||||
@@ -148,14 +143,14 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string web.image.tag="${imageTag}"`,
|
||||
`--set graphql.replicaCount=${replica.graphql}`,
|
||||
`--set-string graphql.image.tag="${imageTag}"`,
|
||||
`--set graphql.app.host=${hosts[0]}`,
|
||||
`--set graphql.app.host=${host}`,
|
||||
`--set sync.replicaCount=${replica.sync}`,
|
||||
`--set-string sync.image.tag="${imageTag}"`,
|
||||
`--set-string renderer.image.tag="${imageTag}"`,
|
||||
`--set renderer.app.host=${hosts[0]}`,
|
||||
`--set renderer.app.host=${host}`,
|
||||
`--set renderer.replicaCount=${replica.renderer}`,
|
||||
`--set-string doc.image.tag="${imageTag}"`,
|
||||
`--set doc.app.host=${hosts[0]}`,
|
||||
`--set doc.app.host=${host}`,
|
||||
`--set doc.replicaCount=${replica.doc}`,
|
||||
...serviceAnnotations,
|
||||
...resources,
|
||||
|
||||
@@ -36,8 +36,7 @@ spec:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.global.ingress.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
- host: "{{ .Values.global.ingress.host }}"
|
||||
http:
|
||||
paths:
|
||||
- path: /socket.io
|
||||
@@ -46,34 +45,33 @@ spec:
|
||||
service:
|
||||
name: affine-sync
|
||||
port:
|
||||
number: {{ $.Values.sync.service.port }}
|
||||
number: {{ .Values.sync.service.port }}
|
||||
- path: /graphql
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ $.Values.graphql.service.port }}
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ $.Values.graphql.service.port }}
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /workspace
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-renderer
|
||||
port:
|
||||
number: {{ $.Values.renderer.service.port }}
|
||||
number: {{ .Values.renderer.service.port }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-web
|
||||
port:
|
||||
number: {{ $.Values.web.service.port }}
|
||||
{{- end }}
|
||||
number: {{ .Values.web.service.port }}
|
||||
{{- end }}
|
||||
|
||||
@@ -4,13 +4,7 @@ global:
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ''
|
||||
# hosts for ingress rules
|
||||
# e.g.
|
||||
# hosts:
|
||||
# - affine.pro
|
||||
# - www.affine.pro
|
||||
hosts:
|
||||
- affine.pro
|
||||
host: affine.pro
|
||||
tls: []
|
||||
secret:
|
||||
secretName: 'server-private-key'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
canEmbedAsEmbedBlock,
|
||||
canEmbedAsIframe,
|
||||
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
||||
@@ -150,10 +149,13 @@ const builtinToolbarConfig = {
|
||||
if (!model) return true;
|
||||
|
||||
const url = model.props.url;
|
||||
// check if the url can be embedded as iframe block or other embed blocks
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return (
|
||||
!canEmbedAsIframe(ctx.std, url) &&
|
||||
!canEmbedAsEmbedBlock(ctx.std, url)
|
||||
!canEmbedAsIframe(ctx.std, url) && options?.viewType !== 'embed'
|
||||
);
|
||||
},
|
||||
run(ctx) {
|
||||
@@ -167,8 +169,15 @@ const builtinToolbarConfig = {
|
||||
|
||||
let blockId: string | undefined;
|
||||
|
||||
// first try to embed as a custom embed block
|
||||
if (canEmbedAsEmbedBlock(ctx.std, url)) {
|
||||
// first try to embed as iframe block
|
||||
if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
blockId = embedIframeService.addEmbedIframeBlock(
|
||||
{ url, caption, title, description },
|
||||
parent.id,
|
||||
index
|
||||
);
|
||||
} else {
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
@@ -193,13 +202,6 @@ const builtinToolbarConfig = {
|
||||
parent,
|
||||
index
|
||||
);
|
||||
} else if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
blockId = embedIframeService.addEmbedIframeBlock(
|
||||
{ url, caption, title, description },
|
||||
parent.id,
|
||||
index
|
||||
);
|
||||
}
|
||||
|
||||
if (!blockId) return;
|
||||
@@ -377,8 +379,27 @@ const builtinSurfaceToolbarConfig = {
|
||||
|
||||
let newId: string | undefined;
|
||||
|
||||
// first try to embed as a custom embed block
|
||||
if (canEmbedAsEmbedBlock(ctx.std, url)) {
|
||||
// first try to embed as iframe block
|
||||
if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
const config = embedIframeService.getConfig(url);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const options = config.options;
|
||||
const { widthInSurface, heightInSurface } = options ?? {};
|
||||
bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
|
||||
bound.h =
|
||||
heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
|
||||
|
||||
newId = ctx.store.addBlock(
|
||||
'affine:embed-iframe',
|
||||
{ url, caption, title, description, xywh: bound.serialize() },
|
||||
parent
|
||||
);
|
||||
} else {
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
@@ -408,29 +429,8 @@ const builtinSurfaceToolbarConfig = {
|
||||
},
|
||||
parent
|
||||
);
|
||||
} else if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
const config = embedIframeService.getConfig(url);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const options = config.options;
|
||||
const { widthInSurface, heightInSurface } = options ?? {};
|
||||
bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
|
||||
bound.h =
|
||||
heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
|
||||
|
||||
newId = ctx.store.addBlock(
|
||||
'affine:embed-iframe',
|
||||
{ url, caption, title, description, xywh: bound.serialize() },
|
||||
parent
|
||||
);
|
||||
}
|
||||
|
||||
if (!newId) return;
|
||||
|
||||
ctx.command.exec(reassociateConnectorsCommand, { oldId, newId });
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
@@ -449,10 +449,13 @@ const builtinSurfaceToolbarConfig = {
|
||||
when(ctx) {
|
||||
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
||||
if (!model) return false;
|
||||
|
||||
const { url } = model.props;
|
||||
return (
|
||||
canEmbedAsIframe(ctx.std, url) || canEmbedAsEmbedBlock(ctx.std, url)
|
||||
);
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return canEmbedAsIframe(ctx.std, url) || options?.viewType === 'embed';
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
EmbedCardLightVerticalIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
type EmbedCardIcons = {
|
||||
@@ -42,8 +40,3 @@ export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function canEmbedAsEmbedBlock(std: BlockStdScope, url: string) {
|
||||
const options = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
|
||||
return options?.viewType === 'embed';
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GENERIC_DEFAULT_WIDTH_PERCENT = 100;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_NOTE = 400;
|
||||
|
||||
/**
|
||||
* AFFiNE domains that should be excluded from generic embedding
|
||||
* These are based on the centralized cloud constants and known AFFiNE domains
|
||||
*/
|
||||
const AFFINE_DOMAINS = [
|
||||
'affine.pro', // Main AFFiNE domain
|
||||
'app.affine.pro', // Stable cloud domain
|
||||
'insider.affine.pro', // Beta/internal cloud domain
|
||||
'affine.fail', // Canary cloud domain
|
||||
'toeverything.app', // Safety measure for potential future use
|
||||
'apple.getaffineapp.com', // Cloud domain for Apple app
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates if a URL is suitable for generic iframe embedding
|
||||
* Allows HTTPS URLs but excludes AFFiNE domains
|
||||
* @param url The URL to validate
|
||||
* @returns Boolean indicating if the URL can be generically embedded
|
||||
*/
|
||||
function isValidGenericEmbedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Only allow HTTPS for security
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude AFFiNE domains
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
if (
|
||||
AFFINE_DOMAINS.some(
|
||||
domain => hostname === domain || hostname.endsWith(`.${domain}`)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const genericConfig = {
|
||||
name: 'generic',
|
||||
match: (url: string) => isValidGenericEmbedUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGenericEmbedUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
options: {
|
||||
widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
widthPercent: GENERIC_DEFAULT_WIDTH_PERCENT,
|
||||
heightInNote: GENERIC_DEFAULT_HEIGHT_IN_NOTE,
|
||||
allowFullscreen: true,
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
allow: 'clipboard-read; clipboard-write; picture-in-picture;',
|
||||
referrerpolicy: 'no-referrer-when-downgrade',
|
||||
},
|
||||
};
|
||||
|
||||
export const GenericEmbedConfig = EmbedIframeConfigExtension(genericConfig);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ExcalidrawEmbedConfig } from './excalidraw';
|
||||
import { GenericEmbedConfig } from './generic';
|
||||
import { GoogleDocsEmbedConfig } from './google-docs';
|
||||
import { GoogleDriveEmbedConfig } from './google-drive';
|
||||
import { MiroEmbedConfig } from './miro';
|
||||
@@ -11,5 +10,4 @@ export const EmbedIframeConfigExtensions = [
|
||||
MiroEmbedConfig,
|
||||
ExcalidrawEmbedConfig,
|
||||
GoogleDocsEmbedConfig,
|
||||
GenericEmbedConfig,
|
||||
];
|
||||
|
||||
@@ -228,20 +228,23 @@ export const builtinInlineLinkToolbarConfig = {
|
||||
const props = { url };
|
||||
let blockId: string | undefined;
|
||||
|
||||
// first try to embed as iframe block
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
const embedOptions = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
if (embedOptions?.viewType === 'embed') {
|
||||
const flavour = embedOptions.flavour;
|
||||
blockId = ctx.store.addBlock(flavour, props, parent, index + 1);
|
||||
} else if (embedIframeService.canEmbed(url)) {
|
||||
if (embedIframeService.canEmbed(url)) {
|
||||
blockId = embedIframeService.addEmbedIframeBlock(
|
||||
props,
|
||||
parent.id,
|
||||
index + 1
|
||||
);
|
||||
} else {
|
||||
// if not, try to add as other embed link block
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
if (options?.viewType !== 'embed') return;
|
||||
|
||||
const flavour = options.flavour;
|
||||
blockId = ctx.store.addBlock(flavour, props, parent, index + 1);
|
||||
}
|
||||
|
||||
if (!blockId) return;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "comments" (
|
||||
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
|
||||
"id" VARCHAR NOT NULL,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"doc_id" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"content" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
"resolved" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "replies" (
|
||||
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
|
||||
"id" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"comment_id" VARCHAR NOT NULL,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"doc_id" VARCHAR NOT NULL,
|
||||
"content" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "replies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "comments_sid_key" ON "comments"("sid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comments_workspace_id_doc_id_sid_idx" ON "comments"("workspace_id", "doc_id", "sid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comments_workspace_id_doc_id_updated_at_idx" ON "comments"("workspace_id", "doc_id", "updated_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comments_user_id_idx" ON "comments"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "replies_sid_key" ON "replies"("sid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "replies_comment_id_sid_idx" ON "replies"("comment_id", "sid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "replies_workspace_id_doc_id_updated_at_idx" ON "replies"("workspace_id", "doc_id", "updated_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "replies_user_id_idx" ON "replies"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "replies" ADD CONSTRAINT "replies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "replies" ADD CONSTRAINT "replies_comment_id_fkey" FOREIGN KEY ("comment_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "comment_attachments" (
|
||||
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"doc_id" VARCHAR NOT NULL,
|
||||
"key" VARCHAR NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"mime" VARCHAR NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" VARCHAR,
|
||||
|
||||
CONSTRAINT "comment_attachments_pkey" PRIMARY KEY ("workspace_id","doc_id","key")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "comment_attachments_sid_key" ON "comment_attachments"("sid");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comment_attachments" ADD CONSTRAINT "comment_attachments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comment_attachments" ADD CONSTRAINT "comment_attachments_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -32,7 +32,6 @@
|
||||
"@ai-sdk/google": "^1.2.18",
|
||||
"@ai-sdk/google-vertex": "^2.2.23",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@ai-sdk/openai-compatible": "^0.2.14",
|
||||
"@ai-sdk/perplexity": "^1.1.9",
|
||||
"@apollo/server": "^4.11.3",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
|
||||
@@ -46,9 +46,6 @@ model User {
|
||||
// receive notifications
|
||||
notifications Notification[] @relation("user_notifications")
|
||||
settings UserSettings?
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -129,8 +126,6 @@ model Workspace {
|
||||
blobs Blob[]
|
||||
ignoredDocs AiWorkspaceIgnoredDocs[]
|
||||
embedFiles AiWorkspaceFiles[]
|
||||
comments Comment[]
|
||||
commentAttachments CommentAttachment[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
@@ -861,70 +856,3 @@ model UserSettings {
|
||||
|
||||
@@map("user_settings")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
// NOTE: manually set this column type to identity in migration file
|
||||
sid Int @unique @default(autoincrement()) @db.Integer
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
content Json @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
// whether the comment is resolved
|
||||
resolved Boolean @default(false) @map("resolved")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
replies Reply[]
|
||||
|
||||
@@index([workspaceId, docId, sid])
|
||||
@@index([workspaceId, docId, updatedAt])
|
||||
@@index([userId])
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
model Reply {
|
||||
// NOTE: manually set this column type to identity in migration file
|
||||
sid Int @unique @default(autoincrement()) @db.Integer
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
commentId String @map("comment_id") @db.VarChar
|
||||
// query new replies by workspaceId and docId
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
content Json @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([commentId, sid])
|
||||
@@index([workspaceId, docId, updatedAt])
|
||||
@@index([userId])
|
||||
@@map("replies")
|
||||
}
|
||||
|
||||
model CommentAttachment {
|
||||
// NOTE: manually set this column type to identity in migration file
|
||||
sid Int @unique @default(autoincrement())
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
key String @db.VarChar
|
||||
size Int @db.Integer
|
||||
mime String @db.VarChar
|
||||
name String @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdBy String? @map("created_by") @db.VarChar
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
// will delete creator record if creator's account is deleted
|
||||
createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@id([workspaceId, docId, key])
|
||||
@@map("comment_attachments")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { Due } from '../../base';
|
||||
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { Models } from '../../models';
|
||||
@@ -125,7 +126,7 @@ test('should be able to refresh session if needed', async t => {
|
||||
sessionId,
|
||||
},
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
|
||||
expiresAt: Due.after('1h'),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
import * as Sinon from 'sinon';
|
||||
|
||||
import { Due } from '../../base';
|
||||
import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||
import { DocStorageOptions } from '../../core/doc/options';
|
||||
import { DocRecord } from '../../core/doc/storage';
|
||||
@@ -122,7 +123,7 @@ test('should create history if time diff is larger than interval config and stat
|
||||
|
||||
// @ts-expect-error private method
|
||||
Sinon.stub(adapter, 'lastDocHistory').resolves({
|
||||
timestamp: new Date(timestamp.getTime() - 1000 * 60 * 20),
|
||||
timestamp: Due.before('20m', timestamp),
|
||||
state: Buffer.from([0, 1]),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Config } from '../../base/config';
|
||||
import { SessionModel } from '../../models/session';
|
||||
import { UserModel } from '../../models/user';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
import { Due } from '../../base/utils';
|
||||
|
||||
interface Context {
|
||||
config: Config;
|
||||
@@ -137,9 +138,7 @@ test('should not refresh userSession when expires time not hit ttr', async t =>
|
||||
let newExpiresAt =
|
||||
await t.context.session.refreshUserSessionIfNeeded(userSession);
|
||||
t.is(newExpiresAt, undefined);
|
||||
userSession.expiresAt = new Date(
|
||||
userSession.expiresAt!.getTime() - t.context.config.auth.session.ttr * 1000
|
||||
);
|
||||
userSession.expiresAt = Due.before(t.context.config.auth.session.ttr);
|
||||
newExpiresAt =
|
||||
await t.context.session.refreshUserSessionIfNeeded(userSession);
|
||||
t.is(newExpiresAt, undefined);
|
||||
@@ -154,9 +153,9 @@ test('should not refresh userSession when expires time hit ttr', async t => {
|
||||
user.id,
|
||||
session.id
|
||||
);
|
||||
const ttr = t.context.config.auth.session.ttr * 2;
|
||||
userSession.expiresAt = new Date(
|
||||
userSession.expiresAt!.getTime() - ttr * 1000
|
||||
userSession.expiresAt!.getTime() -
|
||||
Due.ms(t.context.config.auth.session.ttr) * 2
|
||||
);
|
||||
const newExpiresAt =
|
||||
await t.context.session.refreshUserSessionIfNeeded(userSession);
|
||||
|
||||
@@ -37,10 +37,6 @@ test.before(async t => {
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
hosts: ['localhost', 'test.affine.dev'],
|
||||
https: true,
|
||||
},
|
||||
}),
|
||||
AppModule,
|
||||
],
|
||||
@@ -94,38 +90,6 @@ test("should be able to redirect to oauth provider's login page", async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to redirect to oauth provider with multiple hosts', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.set('host', 'test.affine.dev')
|
||||
.send({ provider: 'Google' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const { url } = res.body;
|
||||
|
||||
const redirect = new URL(url);
|
||||
t.is(redirect.origin, 'https://accounts.google.com');
|
||||
|
||||
t.is(redirect.pathname, '/o/oauth2/v2/auth');
|
||||
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
|
||||
t.is(
|
||||
redirect.searchParams.get('redirect_uri'),
|
||||
'https://test.affine.dev/oauth/callback'
|
||||
);
|
||||
t.is(redirect.searchParams.get('response_type'), 'code');
|
||||
t.is(redirect.searchParams.get('prompt'), 'select_account');
|
||||
t.truthy(redirect.searchParams.get('state'));
|
||||
// state should be a json string
|
||||
const state = JSON.parse(redirect.searchParams.get('state')!);
|
||||
t.is(state.provider, 'Google');
|
||||
t.regex(
|
||||
state.state,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to redirect to oauth provider with client_nonce', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Sinon from 'sinon';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { EventBus } from '../../base';
|
||||
import { Due, EventBus } from '../../base';
|
||||
import { ConfigFactory, ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
@@ -129,8 +129,8 @@ const sub: Stripe.Subscription = {
|
||||
object: 'subscription',
|
||||
cancel_at_period_end: false,
|
||||
canceled_at: null,
|
||||
current_period_end: unixNow() + 60 * 60 * 24 * 30,
|
||||
current_period_start: unixNow() - 60 * 60 * 24 * 1,
|
||||
current_period_end: Due.after('30d').getTime() / 1000,
|
||||
current_period_start: Due.before('1d').getTime() / 1000,
|
||||
// @ts-expect-error stub
|
||||
customer: {
|
||||
id: 'cus_1',
|
||||
@@ -914,7 +914,7 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
|
||||
},
|
||||
],
|
||||
start_date: unixNow(),
|
||||
end_date: unixNow() + 30 * 24 * 60 * 60,
|
||||
end_date: Due.after('30d').getTime() / 1000,
|
||||
},
|
||||
{
|
||||
items: [
|
||||
@@ -924,7 +924,7 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
start_date: unixNow() + 30 * 24 * 60 * 60,
|
||||
start_date: Due.after('30d').getTime() / 1000,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1550,10 +1550,7 @@ test('should be able to subscribe onetime payment subscription', async t => {
|
||||
t.is(subInDB?.recurring, SubscriptionRecurring.Monthly);
|
||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||
t.is(subInDB?.stripeSubscriptionId, null);
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
t.is(subInDB?.end?.toDateString(), Due.after('30d').toDateString());
|
||||
});
|
||||
|
||||
test('should be able to accumulate onetime payment subscription period', async t => {
|
||||
@@ -1574,7 +1571,7 @@ test('should be able to accumulate onetime payment subscription period', async t
|
||||
});
|
||||
|
||||
// add 365 days
|
||||
t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
t.is(subInDB!.end!.getTime(), Due.after('1y', end).getTime());
|
||||
});
|
||||
|
||||
test('should be able to recalculate onetime payment subscription period after expiration', async t => {
|
||||
@@ -1599,10 +1596,7 @@ test('should be able to recalculate onetime payment subscription period after ex
|
||||
});
|
||||
|
||||
// add 365 days from now
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
t.is(subInDB?.end?.toDateString(), Due.after('1y').toDateString());
|
||||
});
|
||||
|
||||
test('should not accumulate onetime payment subscription period for redeemed invoices', async t => {
|
||||
@@ -1617,10 +1611,7 @@ test('should not accumulate onetime payment subscription period for redeemed inv
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
t.is(subInDB?.end?.toDateString(), Due.after('1y').toDateString());
|
||||
});
|
||||
|
||||
// TEAM
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ClsModule } from 'nestjs-cls';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import {
|
||||
getRequestFromHost,
|
||||
getRequestIdFromHost,
|
||||
getRequestIdFromRequest,
|
||||
ScannerModule,
|
||||
@@ -67,9 +66,8 @@ export const FunctionalityModules = [
|
||||
// make every request has a unique id to tracing
|
||||
return getRequestIdFromRequest(req, 'http');
|
||||
},
|
||||
setup(cls, req: Request, res: Response) {
|
||||
setup(cls, _req, res: Response) {
|
||||
res.setHeader('X-Request-Id', cls.getId());
|
||||
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||
},
|
||||
},
|
||||
// for websocket connection
|
||||
@@ -81,10 +79,6 @@ export const FunctionalityModules = [
|
||||
// make every request has a unique id to tracing
|
||||
return getRequestIdFromHost(context);
|
||||
},
|
||||
setup(cls, context: ExecutionContext) {
|
||||
const req = getRequestFromHost(context);
|
||||
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import Redis from 'ioredis';
|
||||
import { type Duration, Due } from '../utils';
|
||||
|
||||
export interface CacheSetOptions {
|
||||
/**
|
||||
* in milliseconds
|
||||
*/
|
||||
ttl?: number;
|
||||
ttl?: Duration;
|
||||
}
|
||||
|
||||
export class CacheProvider {
|
||||
@@ -30,7 +31,7 @@ export class CacheProvider {
|
||||
): Promise<boolean> {
|
||||
if (opts.ttl) {
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value), 'PX', opts.ttl)
|
||||
.set(key, JSON.stringify(value), 'PX', Due.ms(opts.ttl))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
@@ -56,7 +57,7 @@ export class CacheProvider {
|
||||
): Promise<boolean> {
|
||||
if (opts.ttl) {
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
|
||||
.set(key, JSON.stringify(value), 'PX', Due.ms(opts.ttl), 'NX')
|
||||
.then(v => !!v)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ test('should override correctly', t => {
|
||||
// keyed config
|
||||
// 'session.ttl', 'session.ttr'
|
||||
session: {
|
||||
ttl: 2000,
|
||||
ttr: 1000,
|
||||
ttl: '1M',
|
||||
ttr: '1d',
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
@@ -131,7 +131,7 @@ test('should override correctly', t => {
|
||||
},
|
||||
allowSignup: true,
|
||||
session: {
|
||||
ttl: 3000,
|
||||
ttl: '2M',
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
@@ -159,8 +159,8 @@ test('should override correctly', t => {
|
||||
|
||||
// right merged to left
|
||||
t.deepEqual(config.auth.session, {
|
||||
ttl: 3000,
|
||||
ttr: 1000,
|
||||
ttl: '2M',
|
||||
ttr: '1d',
|
||||
});
|
||||
|
||||
// right covered left
|
||||
|
||||
@@ -907,14 +907,4 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
args: { reason: 'string' },
|
||||
message: ({ reason }) => `Invalid indexer input: ${reason}`,
|
||||
},
|
||||
|
||||
// comment and reply errors
|
||||
comment_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Comment not found.',
|
||||
},
|
||||
reply_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Reply not found.',
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
|
||||
@@ -1067,18 +1067,6 @@ export class InvalidIndexerInput extends UserFriendlyError {
|
||||
super('invalid_input', 'invalid_indexer_input', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'comment_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReplyNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'reply_not_found', message);
|
||||
}
|
||||
}
|
||||
export enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
NETWORK_ERROR,
|
||||
@@ -1214,9 +1202,7 @@ export enum ErrorNames {
|
||||
INVALID_APP_CONFIG_INPUT,
|
||||
SEARCH_PROVIDER_NOT_FOUND,
|
||||
INVALID_SEARCH_PROVIDER_REQUEST,
|
||||
INVALID_INDEXER_INPUT,
|
||||
COMMENT_NOT_FOUND,
|
||||
REPLY_NOT_FOUND
|
||||
INVALID_INDEXER_INPUT
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
|
||||
@@ -12,7 +12,6 @@ test.beforeEach(async t => {
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
@@ -29,7 +28,6 @@ test('can factor base url correctly with specified external url', t => {
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
@@ -44,7 +42,6 @@ test('can factor base url correctly with specified external url and path', t =>
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com/anything',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
@@ -59,7 +56,6 @@ test('can factor base url correctly with specified external url with port', t =>
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com:123',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
},
|
||||
@@ -99,7 +95,7 @@ test('can safe redirect', t => {
|
||||
|
||||
function deny(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(t.context.url.baseUrl));
|
||||
t.true(spy.calledOnceWith(t.context.url.home));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
@@ -110,38 +106,3 @@ test('can safe redirect', t => {
|
||||
].forEach(allow);
|
||||
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
|
||||
});
|
||||
|
||||
test('can get request origin', t => {
|
||||
t.is(t.context.url.requestOrigin, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url', t => {
|
||||
t.is(t.context.url.requestBaseUrl, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url with multiple hosts', t => {
|
||||
// mock cls
|
||||
const cls = new Map<string, string>();
|
||||
const url = new URLHelper(
|
||||
{
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local1',
|
||||
hosts: ['app.affine.local1', 'app.affine.local2'],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
},
|
||||
} as any,
|
||||
cls as any
|
||||
);
|
||||
|
||||
// no cls, use default origin
|
||||
t.is(url.requestOrigin, 'https://app.affine.local1');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local1');
|
||||
|
||||
// set cls
|
||||
cls.set(CLS_REQUEST_HOST, 'app.affine.local2');
|
||||
t.is(url.requestOrigin, 'https://app.affine.local2');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local2');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { isIP } from 'node:net';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { OnEvent } from '../event';
|
||||
@@ -12,13 +11,10 @@ export class URLHelper {
|
||||
redirectAllowHosts!: string[];
|
||||
|
||||
origin!: string;
|
||||
allowedOrigins!: string[];
|
||||
baseUrl!: string;
|
||||
home!: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly cls?: ClsService
|
||||
) {
|
||||
constructor(private readonly config: Config) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -38,40 +34,19 @@ export class URLHelper {
|
||||
this.baseUrl =
|
||||
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
|
||||
} else {
|
||||
this.origin = this.convertHostToOrigin(this.config.server.host);
|
||||
this.origin = [
|
||||
this.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
this.config.server.host,
|
||||
this.config.server.host === 'localhost' || isIP(this.config.server.host)
|
||||
? `:${this.config.server.port}`
|
||||
: '',
|
||||
].join('');
|
||||
this.baseUrl = this.origin + this.config.server.path;
|
||||
}
|
||||
|
||||
this.home = this.baseUrl;
|
||||
this.redirectAllowHosts = [this.baseUrl];
|
||||
|
||||
this.allowedOrigins = [this.origin];
|
||||
if (this.config.server.hosts.length > 0) {
|
||||
for (const host of this.config.server.hosts) {
|
||||
this.allowedOrigins.push(this.convertHostToOrigin(host));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get requestOrigin() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
// support multiple hosts
|
||||
const requestHost = this.cls?.get<string | undefined>(CLS_REQUEST_HOST);
|
||||
if (!requestHost || !this.config.server.hosts.includes(requestHost)) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
return this.convertHostToOrigin(requestHost);
|
||||
}
|
||||
|
||||
get requestBaseUrl() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
return this.requestOrigin + this.config.server.path;
|
||||
}
|
||||
|
||||
stringify(query: Record<string, any>) {
|
||||
@@ -97,7 +72,7 @@ export class URLHelper {
|
||||
}
|
||||
|
||||
url(path: string, query: Record<string, any> = {}) {
|
||||
const url = new URL(path, this.requestOrigin);
|
||||
const url = new URL(path, this.origin);
|
||||
|
||||
for (const key in query) {
|
||||
url.searchParams.set(key, query[key]);
|
||||
@@ -112,7 +87,7 @@ export class URLHelper {
|
||||
|
||||
safeRedirect(res: Response, to: string) {
|
||||
try {
|
||||
const finalTo = new URL(decodeURIComponent(to), this.requestBaseUrl);
|
||||
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
|
||||
|
||||
for (const host of this.redirectAllowHosts) {
|
||||
const hostURL = new URL(host);
|
||||
@@ -128,7 +103,7 @@ export class URLHelper {
|
||||
}
|
||||
|
||||
// redirect to home if the url is invalid
|
||||
return res.redirect(this.baseUrl);
|
||||
return res.redirect(this.home);
|
||||
}
|
||||
|
||||
verify(url: string | URL) {
|
||||
@@ -143,13 +118,4 @@ export class URLHelper {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private convertHostToOrigin(host: string) {
|
||||
return [
|
||||
this.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
host,
|
||||
host === 'localhost' || isIP(host) ? `:${this.config.server.port}` : '',
|
||||
].join('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type QueueOptions } from 'bullmq';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { QueueRedis } from '../../redis';
|
||||
import { Due } from '../../utils';
|
||||
import { Queue, QUEUES } from './def';
|
||||
import { JobExecutor } from './executor';
|
||||
import { JobQueue } from './queue';
|
||||
@@ -40,7 +41,7 @@ export class JobModule {
|
||||
...QUEUES.map(name => {
|
||||
if (name === Queue.NIGHTLY_JOB) {
|
||||
// avoid nightly jobs been run multiple times
|
||||
return { name, removeOnComplete: { age: 1000 * 60 * 60 } };
|
||||
return { name, removeOnComplete: { age: Due.ms('1m') } };
|
||||
}
|
||||
return { name };
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './provider';
|
||||
import { autoMetadata, SIGNED_URL_EXPIRED, toBuffer } from './utils';
|
||||
import { autoMetadata, SIGNED_URL_EXPIRED_SEC, toBuffer } from './utils';
|
||||
|
||||
export interface S3StorageConfig extends S3ClientConfig {
|
||||
usePresignedURL?: {
|
||||
@@ -138,7 +138,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
}),
|
||||
{ expiresIn: SIGNED_URL_EXPIRED }
|
||||
{ expiresIn: SIGNED_URL_EXPIRED_SEC }
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { crc32 } from '@node-rs/crc32';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { getMime } from '../../../native';
|
||||
import { Due } from '../../utils';
|
||||
import { BlobInputType, PutObjectMetadata } from './provider';
|
||||
|
||||
export async function toBuffer(input: BlobInputType): Promise<Buffer> {
|
||||
@@ -43,4 +44,4 @@ export function autoMetadata(
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
|
||||
export const SIGNED_URL_EXPIRED_SEC = Due.s('1h');
|
||||
|
||||
@@ -53,26 +53,28 @@ function parse(str: string): DurationInput {
|
||||
return input;
|
||||
}
|
||||
|
||||
export type Duration = string | DurationInput;
|
||||
|
||||
export const Due = {
|
||||
ms: (dueStr: string | DurationInput) => {
|
||||
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
|
||||
ms: (duration: Duration) => {
|
||||
const input = typeof duration === 'string' ? parse(duration) : duration;
|
||||
return Object.entries(input).reduce((duration, [unit, val]) => {
|
||||
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0) * 1000;
|
||||
}, 0);
|
||||
},
|
||||
s: (dueStr: string | DurationInput) => {
|
||||
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
|
||||
s: (duration: Duration) => {
|
||||
const input = typeof duration === 'string' ? parse(duration) : duration;
|
||||
return Object.entries(input).reduce((duration, [unit, val]) => {
|
||||
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0);
|
||||
}, 0);
|
||||
},
|
||||
parse,
|
||||
after: (dueStr: string | number | DurationInput, date?: Date) => {
|
||||
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
|
||||
after: (duration: Duration, date?: Date) => {
|
||||
const timestamp = Due.ms(duration);
|
||||
return new Date((date?.getTime() ?? Date.now()) + timestamp);
|
||||
},
|
||||
before: (dueStr: string | number | DurationInput, date?: Date) => {
|
||||
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
|
||||
before: (duration: Duration, date?: Date) => {
|
||||
const timestamp = Due.ms(duration);
|
||||
return new Date((date?.getTime() ?? Date.now()) - timestamp);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const OneKB = 1024;
|
||||
export const OneMB = OneKB * OneKB;
|
||||
export const OneGB = OneKB * OneMB;
|
||||
export const OneDay = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { defineModuleConfig } from '../../base';
|
||||
|
||||
export interface AuthConfig {
|
||||
session: {
|
||||
ttl: number;
|
||||
ttr: number;
|
||||
ttl: string;
|
||||
ttr: string;
|
||||
};
|
||||
allowSignup: boolean;
|
||||
requireEmailDomainVerification: boolean;
|
||||
@@ -60,10 +60,10 @@ defineModuleConfig('auth', {
|
||||
},
|
||||
'session.ttl': {
|
||||
desc: 'Application auth expiration time in seconds.',
|
||||
default: 60 * 60 * 24 * 15, // 15 days
|
||||
default: '15d',
|
||||
},
|
||||
'session.ttr': {
|
||||
desc: 'Application auth time to refresh in seconds.',
|
||||
default: 60 * 60 * 24 * 7, // 7 days
|
||||
default: '7d',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -199,21 +199,17 @@ export class AuthController {
|
||||
throw new WrongSignInCredentials({ email });
|
||||
}
|
||||
|
||||
const ttlInSec = 30 * 60;
|
||||
const ttl = '30m';
|
||||
const token = await this.models.verificationToken.create(
|
||||
TokenType.SignIn,
|
||||
email,
|
||||
ttlInSec
|
||||
ttl
|
||||
);
|
||||
|
||||
const otp = this.crypto.otp();
|
||||
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
|
||||
const cacheKey = OTP_CACHE_KEY(otp);
|
||||
await this.cache.set(
|
||||
cacheKey,
|
||||
{ token, clientNonce },
|
||||
{ ttl: ttlInSec * 1000 }
|
||||
);
|
||||
await this.cache.set(cacheKey, { token, clientNonce }, { ttl });
|
||||
|
||||
const magicLink = this.url.link(callbackUrl, {
|
||||
token: otp,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, SignUpForbidden } from '../../base';
|
||||
import { Config, type Duration, SignUpForbidden } from '../../base';
|
||||
import { Models, type User, type UserSession } from '../../models';
|
||||
import { FeatureService } from '../features';
|
||||
import { Mailer } from '../mail/mailer';
|
||||
@@ -128,7 +128,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return await this.models.session.findUserSessionsBySessionId(sessionId);
|
||||
}
|
||||
|
||||
async createUserSession(userId: string, sessionId?: string, ttl?: number) {
|
||||
async createUserSession(userId: string, sessionId?: string, ttl?: Duration) {
|
||||
return await this.models.session.createOrRefreshUserSession(
|
||||
userId,
|
||||
sessionId,
|
||||
@@ -157,7 +157,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
async refreshUserSessionIfNeeded(
|
||||
res: Response,
|
||||
userSession: UserSession,
|
||||
ttr?: number
|
||||
ttr?: Duration
|
||||
): Promise<boolean> {
|
||||
const newExpiresAt = await this.models.session.refreshUserSessionIfNeeded(
|
||||
userSession,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { defineModuleConfig } from '../../base';
|
||||
|
||||
export interface ServerFlags {
|
||||
earlyAccessControl: boolean;
|
||||
allowGuestDemoWorkspace: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -13,7 +12,6 @@ declare global {
|
||||
externalUrl?: string;
|
||||
https: boolean;
|
||||
host: string;
|
||||
hosts: ConfigItem<string[]>;
|
||||
port: number;
|
||||
path: string;
|
||||
name?: string;
|
||||
@@ -54,11 +52,6 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
|
||||
default: 'localhost',
|
||||
env: 'AFFINE_SERVER_HOST',
|
||||
},
|
||||
hosts: {
|
||||
desc: 'Multiple hosts the server will accept requests from.',
|
||||
default: [],
|
||||
shape: z.array(z.string()),
|
||||
},
|
||||
port: {
|
||||
desc: 'Which port the server will listen on.',
|
||||
default: 3010,
|
||||
@@ -76,8 +69,4 @@ defineModuleConfig('flags', {
|
||||
desc: 'Only allow users with early access features to access the app',
|
||||
default: false,
|
||||
},
|
||||
allowGuestDemoWorkspace: {
|
||||
desc: 'Whether allow guest users to create demo workspaces.',
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,10 +82,9 @@ export class ServerConfigResolver {
|
||||
? 'AFFiNE Beta Cloud'
|
||||
: 'AFFiNE Cloud'),
|
||||
version: env.version,
|
||||
baseUrl: this.url.requestBaseUrl,
|
||||
baseUrl: this.url.home,
|
||||
type: env.DEPLOYMENT_TYPE,
|
||||
features: this.server.features,
|
||||
allowGuestDemoWorkspace: this.config.flags.allowGuestDemoWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,4 @@ export class ServerConfigType {
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Whether allow guest users to create demo workspaces.',
|
||||
})
|
||||
allowGuestDemoWorkspace!: boolean;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { chunk } from 'lodash-es';
|
||||
import {
|
||||
DocHistoryNotFound,
|
||||
DocNotFound,
|
||||
Due,
|
||||
EventBus,
|
||||
FailedToSaveUpdates,
|
||||
FailedToUpsertSnapshot,
|
||||
@@ -251,7 +252,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
force ||
|
||||
// last history created before interval in configs
|
||||
lastHistoryTimestamp <
|
||||
snapshot.timestamp - this.options.historyMinInterval(snapshot.spaceId)
|
||||
snapshot.timestamp -
|
||||
Due.ms(this.options.historyMinInterval(snapshot.spaceId))
|
||||
) {
|
||||
shouldCreateHistory = true;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global {
|
||||
interface AppConfigSchema {
|
||||
doc: {
|
||||
history: {
|
||||
interval: number;
|
||||
interval: string;
|
||||
};
|
||||
experimental: {
|
||||
yocto: boolean;
|
||||
@@ -20,6 +20,6 @@ defineModuleConfig('doc', {
|
||||
},
|
||||
'history.interval': {
|
||||
desc: 'The minimum time interval in milliseconds of creating a new history snapshot when doc get updated.',
|
||||
default: 1000 * 60 * 10 /* 10 mins */,
|
||||
default: '10m',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
|
||||
import { type DocDiff, type DocRecord } from './storage';
|
||||
|
||||
const DOC_CONTENT_CACHE_7_DAYS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface WorkspaceDocInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -90,7 +88,7 @@ export abstract class DocReader {
|
||||
const content = await this.getDocContentWithoutCache(workspaceId, docId);
|
||||
if (content) {
|
||||
await this.cache.set(cacheKey, content, {
|
||||
ttl: DOC_CONTENT_CACHE_7_DAYS,
|
||||
ttl: '7d',
|
||||
});
|
||||
}
|
||||
return content;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { NotificationNotFound } from '../../../base';
|
||||
import { Due, NotificationNotFound } from '../../../base';
|
||||
import {
|
||||
DocMode,
|
||||
MentionNotificationBody,
|
||||
@@ -381,7 +381,7 @@ test('should clean expired notifications', async t => {
|
||||
// wait for 100 days
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 100,
|
||||
now: Due.after('100d'),
|
||||
});
|
||||
await t.context.models.notification.cleanExpiredNotifications();
|
||||
count = await notificationService.countByUserId(member.id);
|
||||
@@ -390,7 +390,7 @@ test('should clean expired notifications', async t => {
|
||||
// wait for 1 year
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
now: Due.after('1y'),
|
||||
});
|
||||
await t.context.models.notification.cleanExpiredNotifications();
|
||||
count = await notificationService.countByUserId(member.id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OneDay, OneKB } from '../../base';
|
||||
import { Due, OneKB } from '../../base';
|
||||
|
||||
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
@@ -14,6 +14,7 @@ export function formatSize(bytes: number, decimals: number = 2): string {
|
||||
);
|
||||
}
|
||||
|
||||
const ONE_DAY_IN_MS = Due.ms('1d');
|
||||
export function formatDate(ms: number): string {
|
||||
return `${(ms / OneDay).toFixed(0)} days`;
|
||||
return `${(ms / ONE_DAY_IN_MS).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface VersionConfig {
|
||||
enabled: boolean;
|
||||
requiredVersion: string;
|
||||
};
|
||||
allowGuestDemoWorkspace?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -29,8 +28,4 @@ defineModuleConfig('client', {
|
||||
desc: "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.",
|
||||
default: '>=0.20.0',
|
||||
},
|
||||
allowGuestDemoWorkspace: {
|
||||
desc: 'Allow guests to access demo workspace.',
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -403,8 +403,7 @@ export class WorkspaceDocResolver {
|
||||
const allowed = await this.cache.setnx(
|
||||
`fixingOwner:${workspaceId}:${docId}`,
|
||||
1,
|
||||
// TODO(@forehalo): we definitely need a timer helper
|
||||
{ ttl: 1000 * 60 * 60 * 24 }
|
||||
{ ttl: '1d' }
|
||||
);
|
||||
|
||||
// fixed by other instance
|
||||
|
||||
@@ -174,13 +174,11 @@ export class InviteResult {
|
||||
error?: object;
|
||||
}
|
||||
|
||||
const Day = 24 * 60 * 60 * 1000;
|
||||
|
||||
export enum WorkspaceInviteLinkExpireTime {
|
||||
OneDay = Day,
|
||||
ThreeDays = 3 * Day,
|
||||
OneWeek = 7 * Day,
|
||||
OneMonth = 30 * Day,
|
||||
OneDay = '1d',
|
||||
ThreeDays = '3d',
|
||||
OneWeek = '1w',
|
||||
OneMonth = '1M',
|
||||
}
|
||||
|
||||
registerEnumType(WorkspaceInviteLinkExpireTime, {
|
||||
|
||||
@@ -12,8 +12,6 @@ declare global {
|
||||
var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T;
|
||||
// oxlint-disable-next-line no-var
|
||||
var CUSTOM_CONFIG_PATH: string;
|
||||
// oxlint-disable-next-line no-var
|
||||
var CLS_REQUEST_HOST: 'CLS_REQUEST_HOST';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +53,6 @@ export type AppEnv = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
globalThis.CLS_REQUEST_HOST = 'CLS_REQUEST_HOST';
|
||||
globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config');
|
||||
globalThis.readEnv = function readEnv<T>(
|
||||
env: string,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Snapshot report for `src/models/__tests__/comment.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `comment.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should create and get a reply
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
text: 'test reply',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
type: 'paragraph',
|
||||
}
|
||||
|
||||
## should update a reply
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
text: 'test reply2',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
type: 'paragraph',
|
||||
}
|
||||
Binary file not shown.
@@ -1,125 +0,0 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Models } from '..';
|
||||
|
||||
const module = await createModule();
|
||||
const models = module.get(Models);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should upsert comment attachment', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
// add
|
||||
const item = await models.commentAttachment.upsert({
|
||||
workspaceId: workspace.id,
|
||||
docId: 'test-doc-id',
|
||||
key: 'test-key',
|
||||
name: 'test-name',
|
||||
mime: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
t.is(item.workspaceId, workspace.id);
|
||||
t.is(item.docId, 'test-doc-id');
|
||||
t.is(item.key, 'test-key');
|
||||
t.is(item.mime, 'text/plain');
|
||||
t.is(item.size, 100);
|
||||
t.truthy(item.createdAt);
|
||||
|
||||
// update
|
||||
const item2 = await models.commentAttachment.upsert({
|
||||
workspaceId: workspace.id,
|
||||
docId: 'test-doc-id',
|
||||
name: 'test-name',
|
||||
key: 'test-key',
|
||||
mime: 'text/html',
|
||||
size: 200,
|
||||
});
|
||||
|
||||
t.is(item2.workspaceId, workspace.id);
|
||||
t.is(item2.docId, 'test-doc-id');
|
||||
t.is(item2.key, 'test-key');
|
||||
t.is(item2.mime, 'text/html');
|
||||
t.is(item2.size, 200);
|
||||
|
||||
// make sure only one blob is created
|
||||
const items = await models.commentAttachment.list(workspace.id);
|
||||
t.is(items.length, 1);
|
||||
t.deepEqual(items[0], item2);
|
||||
});
|
||||
|
||||
test('should delete comment attachment', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const item = await models.commentAttachment.upsert({
|
||||
workspaceId: workspace.id,
|
||||
docId: 'test-doc-id',
|
||||
key: 'test-key',
|
||||
name: 'test-name',
|
||||
mime: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
await models.commentAttachment.delete(workspace.id, item.docId, item.key);
|
||||
|
||||
const item2 = await models.commentAttachment.get(
|
||||
workspace.id,
|
||||
item.docId,
|
||||
item.key
|
||||
);
|
||||
|
||||
t.is(item2, null);
|
||||
});
|
||||
|
||||
test('should list comment attachments', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const item1 = await models.commentAttachment.upsert({
|
||||
workspaceId: workspace.id,
|
||||
docId: 'test-doc-id',
|
||||
name: 'test-name',
|
||||
key: 'test-key',
|
||||
mime: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
const item2 = await models.commentAttachment.upsert({
|
||||
workspaceId: workspace.id,
|
||||
docId: 'test-doc-id2',
|
||||
name: 'test-name2',
|
||||
key: 'test-key2',
|
||||
mime: 'text/plain',
|
||||
size: 200,
|
||||
});
|
||||
|
||||
const items = await models.commentAttachment.list(workspace.id);
|
||||
|
||||
t.is(items.length, 2);
|
||||
items.sort((a, b) => a.key.localeCompare(b.key));
|
||||
t.is(items[0].key, item1.key);
|
||||
t.is(items[1].key, item2.key);
|
||||
});
|
||||
|
||||
test('should get comment attachment', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const item = await models.commentAttachment.upsert({
|
||||
workspaceId: workspace.id,
|
||||
docId: 'test-doc-id',
|
||||
name: 'test-name',
|
||||
key: 'test-key',
|
||||
mime: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
const item2 = await models.commentAttachment.get(
|
||||
workspace.id,
|
||||
item.docId,
|
||||
item.key
|
||||
);
|
||||
|
||||
t.truthy(item2);
|
||||
t.is(item2?.key, item.key);
|
||||
});
|
||||
@@ -1,526 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Models } from '..';
|
||||
import { CommentChangeAction, Reply } from '../comment';
|
||||
|
||||
const module = await createModule({});
|
||||
|
||||
const models = module.get(Models);
|
||||
const owner = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should throw error when content is null', async t => {
|
||||
const docId = randomUUID();
|
||||
await t.throwsAsync(
|
||||
models.comment.create({
|
||||
// @ts-expect-error test null content
|
||||
content: null,
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
}),
|
||||
{
|
||||
message: /Expected object, received null/,
|
||||
}
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
models.comment.createReply({
|
||||
// @ts-expect-error test null content
|
||||
content: null,
|
||||
commentId: randomUUID(),
|
||||
}),
|
||||
{
|
||||
message: /Expected object, received null/,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should create a comment', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
t.is(comment.createdAt.getTime(), comment.updatedAt.getTime());
|
||||
t.is(comment.deletedAt, null);
|
||||
t.is(comment.resolved, false);
|
||||
t.deepEqual(comment.content, {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('should get a comment', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment1 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const comment2 = await models.comment.get(comment1.id);
|
||||
t.deepEqual(comment2, comment1);
|
||||
t.deepEqual(comment2?.content, {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('should update a comment', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment1 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const comment2 = await models.comment.update({
|
||||
id: comment1.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test2' }],
|
||||
},
|
||||
});
|
||||
t.deepEqual(comment2.content, {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test2' }],
|
||||
});
|
||||
// updatedAt should be changed
|
||||
t.true(comment2.updatedAt.getTime() > comment2.createdAt.getTime());
|
||||
|
||||
const comment3 = await models.comment.get(comment1.id);
|
||||
t.deepEqual(comment3, comment2);
|
||||
});
|
||||
|
||||
test('should delete a comment', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
await models.comment.delete(comment.id);
|
||||
|
||||
const comment2 = await models.comment.get(comment.id);
|
||||
|
||||
t.is(comment2, null);
|
||||
});
|
||||
|
||||
test('should resolve a comment', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const comment2 = await models.comment.resolve({
|
||||
id: comment.id,
|
||||
resolved: true,
|
||||
});
|
||||
t.is(comment2.resolved, true);
|
||||
|
||||
const comment3 = await models.comment.get(comment.id);
|
||||
t.is(comment3!.resolved, true);
|
||||
// updatedAt should be changed
|
||||
t.true(comment3!.updatedAt.getTime() > comment3!.createdAt.getTime());
|
||||
|
||||
const comment4 = await models.comment.resolve({
|
||||
id: comment.id,
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
t.is(comment4.resolved, false);
|
||||
|
||||
const comment5 = await models.comment.get(comment.id);
|
||||
t.is(comment5!.resolved, false);
|
||||
// updatedAt should be changed
|
||||
t.true(comment5!.updatedAt.getTime() > comment3!.updatedAt.getTime());
|
||||
});
|
||||
|
||||
test('should count comments', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment1 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const count = await models.comment.count(workspace.id, docId);
|
||||
t.is(count, 1);
|
||||
|
||||
await models.comment.delete(comment1.id);
|
||||
const count2 = await models.comment.count(workspace.id, docId);
|
||||
t.is(count2, 0);
|
||||
});
|
||||
|
||||
test('should create and get a reply', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const reply = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply' }],
|
||||
},
|
||||
commentId: comment.id,
|
||||
});
|
||||
|
||||
t.snapshot(reply.content);
|
||||
t.is(reply.commentId, comment.id);
|
||||
t.is(reply.userId, owner.id);
|
||||
t.is(reply.workspaceId, workspace.id);
|
||||
t.is(reply.docId, docId);
|
||||
|
||||
const reply2 = await models.comment.getReply(reply.id);
|
||||
t.deepEqual(reply2, reply);
|
||||
});
|
||||
|
||||
test('should throw error reply on a deleted comment', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
await models.comment.delete(comment.id);
|
||||
|
||||
await t.throwsAsync(
|
||||
models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply' }],
|
||||
},
|
||||
commentId: comment.id,
|
||||
}),
|
||||
{
|
||||
message: /Comment not found/,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should update a reply', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const reply = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply' }],
|
||||
},
|
||||
commentId: comment.id,
|
||||
});
|
||||
|
||||
const reply2 = await models.comment.updateReply({
|
||||
id: reply.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply2' }],
|
||||
},
|
||||
});
|
||||
|
||||
t.snapshot(reply2.content);
|
||||
t.true(reply2.updatedAt.getTime() > reply2.createdAt.getTime());
|
||||
});
|
||||
|
||||
test('should delete a reply', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const reply = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply' }],
|
||||
},
|
||||
commentId: comment.id,
|
||||
});
|
||||
|
||||
await models.comment.deleteReply(reply.id);
|
||||
const reply2 = await models.comment.getReply(reply.id);
|
||||
t.is(reply2, null);
|
||||
});
|
||||
|
||||
test('should list comments with replies', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment1 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const comment2 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test2' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const comment3 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test3' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const reply1 = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply1' }],
|
||||
},
|
||||
commentId: comment1.id,
|
||||
});
|
||||
|
||||
const reply2 = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply2' }],
|
||||
},
|
||||
commentId: comment1.id,
|
||||
});
|
||||
|
||||
const reply3 = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply3' }],
|
||||
},
|
||||
commentId: comment1.id,
|
||||
});
|
||||
|
||||
const reply4 = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply4' }],
|
||||
},
|
||||
commentId: comment2.id,
|
||||
});
|
||||
|
||||
const comments = await models.comment.list(workspace.id, docId);
|
||||
t.is(comments.length, 3);
|
||||
t.is(comments[0].id, comment3.id);
|
||||
t.is(comments[1].id, comment2.id);
|
||||
t.is(comments[2].id, comment1.id);
|
||||
t.is(comments[0].replies.length, 0);
|
||||
t.is(comments[1].replies.length, 1);
|
||||
t.is(comments[2].replies.length, 3);
|
||||
|
||||
t.is(comments[1].replies[0].id, reply4.id);
|
||||
t.is(comments[2].replies[0].id, reply1.id);
|
||||
t.is(comments[2].replies[1].id, reply2.id);
|
||||
t.is(comments[2].replies[2].id, reply3.id);
|
||||
|
||||
// list with sid
|
||||
const comments2 = await models.comment.list(workspace.id, docId, {
|
||||
sid: comment2.sid,
|
||||
});
|
||||
t.is(comments2.length, 1);
|
||||
t.is(comments2[0].id, comment1.id);
|
||||
t.is(comments2[0].replies.length, 3);
|
||||
|
||||
// ignore deleted comments
|
||||
await models.comment.delete(comment1.id);
|
||||
const comments3 = await models.comment.list(workspace.id, docId);
|
||||
t.is(comments3.length, 2);
|
||||
t.is(comments3[0].id, comment3.id);
|
||||
t.is(comments3[1].id, comment2.id);
|
||||
t.is(comments3[0].replies.length, 0);
|
||||
t.is(comments3[1].replies.length, 1);
|
||||
});
|
||||
|
||||
test('should list changes', async t => {
|
||||
const docId = randomUUID();
|
||||
const comment1 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const comment2 = await models.comment.create({
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test2' }],
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const reply1 = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply1' }],
|
||||
},
|
||||
commentId: comment1.id,
|
||||
});
|
||||
|
||||
const reply2 = await models.comment.createReply({
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply2' }],
|
||||
},
|
||||
commentId: comment1.id,
|
||||
});
|
||||
|
||||
// all changes
|
||||
const changes1 = await models.comment.listChanges(workspace.id, docId);
|
||||
t.is(changes1.length, 4);
|
||||
t.is(changes1[0].action, CommentChangeAction.update);
|
||||
t.is(changes1[0].id, comment1.id);
|
||||
t.is(changes1[1].action, CommentChangeAction.update);
|
||||
t.is(changes1[1].id, comment2.id);
|
||||
t.is(changes1[2].action, CommentChangeAction.update);
|
||||
t.is(changes1[2].id, reply1.id);
|
||||
t.is(changes1[3].action, CommentChangeAction.update);
|
||||
t.is(changes1[3].id, reply2.id);
|
||||
// reply has commentId
|
||||
t.is((changes1[2].item as Reply).commentId, comment1.id);
|
||||
|
||||
const changes2 = await models.comment.listChanges(workspace.id, docId, {
|
||||
commentUpdatedAt: comment1.updatedAt,
|
||||
replyUpdatedAt: reply1.updatedAt,
|
||||
});
|
||||
t.is(changes2.length, 2);
|
||||
t.is(changes2[0].action, CommentChangeAction.update);
|
||||
t.is(changes2[0].id, comment2.id);
|
||||
t.is(changes2[1].action, CommentChangeAction.update);
|
||||
t.is(changes2[1].id, reply2.id);
|
||||
t.is(changes2[1].commentId, comment1.id);
|
||||
|
||||
// update comment1
|
||||
const comment1Updated = await models.comment.update({
|
||||
id: comment1.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test3' }],
|
||||
},
|
||||
});
|
||||
|
||||
const changes3 = await models.comment.listChanges(workspace.id, docId, {
|
||||
commentUpdatedAt: comment2.updatedAt,
|
||||
replyUpdatedAt: reply2.updatedAt,
|
||||
});
|
||||
t.is(changes3.length, 1);
|
||||
t.is(changes3[0].action, CommentChangeAction.update);
|
||||
t.is(changes3[0].id, comment1Updated.id);
|
||||
|
||||
// delete comment1 and reply1, update reply2
|
||||
await models.comment.delete(comment1.id);
|
||||
await models.comment.deleteReply(reply1.id);
|
||||
await models.comment.updateReply({
|
||||
id: reply2.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply2 updated' }],
|
||||
},
|
||||
});
|
||||
|
||||
const changes4 = await models.comment.listChanges(workspace.id, docId, {
|
||||
commentUpdatedAt: comment1Updated.updatedAt,
|
||||
replyUpdatedAt: reply2.updatedAt,
|
||||
});
|
||||
t.is(changes4.length, 3);
|
||||
t.is(changes4[0].action, CommentChangeAction.delete);
|
||||
t.is(changes4[0].id, comment1.id);
|
||||
t.is(changes4[1].action, CommentChangeAction.delete);
|
||||
t.is(changes4[1].id, reply1.id);
|
||||
t.is(changes4[1].commentId, comment1.id);
|
||||
t.is(changes4[2].action, CommentChangeAction.update);
|
||||
t.is(changes4[2].id, reply2.id);
|
||||
t.is(changes4[2].commentId, comment1.id);
|
||||
|
||||
// no changes
|
||||
const changes5 = await models.comment.listChanges(workspace.id, docId, {
|
||||
commentUpdatedAt: changes4[2].item.updatedAt,
|
||||
replyUpdatedAt: changes4[2].item.updatedAt,
|
||||
});
|
||||
t.is(changes5.length, 0);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { mock } from 'node:test';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
|
||||
import { Due } from '../../base';
|
||||
import { Config } from '../../base/config';
|
||||
import {
|
||||
DocMode,
|
||||
@@ -259,7 +260,7 @@ test('should clean expired notifications', async t => {
|
||||
// wait for 1 year
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
now: Due.after('1y'),
|
||||
});
|
||||
count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 1);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export type CreateCommentAttachmentInput =
|
||||
Prisma.CommentAttachmentUncheckedCreateInput;
|
||||
|
||||
/**
|
||||
* Comment Attachment Model
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommentAttachmentModel extends BaseModel {
|
||||
async upsert(input: CreateCommentAttachmentInput) {
|
||||
return await this.db.commentAttachment.upsert({
|
||||
where: {
|
||||
workspaceId_docId_key: {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: input.docId,
|
||||
key: input.key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: input.name,
|
||||
mime: input.mime,
|
||||
size: input.size,
|
||||
},
|
||||
create: {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: input.docId,
|
||||
key: input.key,
|
||||
name: input.name,
|
||||
mime: input.mime,
|
||||
size: input.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(workspaceId: string, docId: string, key: string) {
|
||||
await this.db.commentAttachment.deleteMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
key,
|
||||
},
|
||||
});
|
||||
this.logger.log(`deleted comment attachment ${workspaceId}/${key}`);
|
||||
}
|
||||
|
||||
async get(workspaceId: string, docId: string, key: string) {
|
||||
return await this.db.commentAttachment.findUnique({
|
||||
where: {
|
||||
workspaceId_docId_key: {
|
||||
workspaceId,
|
||||
docId,
|
||||
key,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list(workspaceId: string, docId?: string) {
|
||||
return await this.db.commentAttachment.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Comment as CommentType, Reply as ReplyType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CommentNotFound } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export interface Comment extends CommentType {
|
||||
content: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Reply extends ReplyType {
|
||||
content: Record<string, any>;
|
||||
}
|
||||
|
||||
// TODO(@fengmk2): move IdSchema to common/base.ts
|
||||
const IdSchema = z.string().trim().min(1).max(100);
|
||||
const JSONSchema = z.record(z.any());
|
||||
|
||||
export const CommentCreateSchema = z.object({
|
||||
workspaceId: IdSchema,
|
||||
docId: IdSchema,
|
||||
userId: IdSchema,
|
||||
content: JSONSchema,
|
||||
});
|
||||
|
||||
export const CommentUpdateSchema = z.object({
|
||||
id: IdSchema,
|
||||
content: JSONSchema,
|
||||
});
|
||||
|
||||
export const CommentResolveSchema = z.object({
|
||||
id: IdSchema,
|
||||
resolved: z.boolean(),
|
||||
});
|
||||
|
||||
export const ReplyCreateSchema = z.object({
|
||||
commentId: IdSchema,
|
||||
userId: IdSchema,
|
||||
content: JSONSchema,
|
||||
});
|
||||
|
||||
export const ReplyUpdateSchema = z.object({
|
||||
id: IdSchema,
|
||||
content: JSONSchema,
|
||||
});
|
||||
|
||||
export type CommentCreate = z.input<typeof CommentCreateSchema>;
|
||||
export type CommentUpdate = z.input<typeof CommentUpdateSchema>;
|
||||
export type CommentResolve = z.input<typeof CommentResolveSchema>;
|
||||
export type ReplyCreate = z.input<typeof ReplyCreateSchema>;
|
||||
export type ReplyUpdate = z.input<typeof ReplyUpdateSchema>;
|
||||
|
||||
export interface CommentWithReplies extends Comment {
|
||||
replies: Reply[];
|
||||
}
|
||||
|
||||
export enum CommentChangeAction {
|
||||
update = 'update',
|
||||
delete = 'delete',
|
||||
}
|
||||
|
||||
export interface DeletedChangeItem {
|
||||
deletedAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CommentChange {
|
||||
action: CommentChangeAction;
|
||||
id: string;
|
||||
commentId?: string;
|
||||
item: Comment | Reply | DeletedChangeItem;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CommentModel extends BaseModel {
|
||||
// #region Comment
|
||||
|
||||
/**
|
||||
* Create a comment
|
||||
* @param input - The comment create input
|
||||
* @returns The created comment
|
||||
*/
|
||||
async create(input: CommentCreate) {
|
||||
const data = CommentCreateSchema.parse(input);
|
||||
return (await this.db.comment.create({
|
||||
data,
|
||||
})) as Comment;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return (await this.db.comment.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
})) as Comment | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a comment content
|
||||
* @param input - The comment update input
|
||||
* @returns The updated comment
|
||||
*/
|
||||
async update(input: CommentUpdate) {
|
||||
const data = CommentUpdateSchema.parse(input);
|
||||
return await this.db.comment.update({
|
||||
where: { id: data.id, deletedAt: null },
|
||||
data: {
|
||||
content: data.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment or reply
|
||||
* @param id - The id of the comment or reply
|
||||
* @returns The deleted comment or reply
|
||||
*/
|
||||
async delete(id: string) {
|
||||
await this.db.comment.update({
|
||||
where: { id, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
this.logger.log(`Comment ${id} deleted`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a comment or not
|
||||
* @param input - The comment resolve input
|
||||
* @returns The resolved comment
|
||||
*/
|
||||
async resolve(input: CommentResolve) {
|
||||
const data = CommentResolveSchema.parse(input);
|
||||
return await this.db.comment.update({
|
||||
where: { id: data.id, deletedAt: null },
|
||||
data: { resolved: data.resolved },
|
||||
});
|
||||
}
|
||||
|
||||
async count(workspaceId: string, docId: string) {
|
||||
return await this.db.comment.count({
|
||||
where: { workspaceId, docId, deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List comments ordered by sid descending
|
||||
* @param workspaceId - The workspace id
|
||||
* @param docId - The doc id
|
||||
* @param options - The options
|
||||
* @returns The list of comments with replies
|
||||
*/
|
||||
async list(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
options?: {
|
||||
sid?: number;
|
||||
take?: number;
|
||||
}
|
||||
): Promise<CommentWithReplies[]> {
|
||||
const comments = (await this.db.comment.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
...(options?.sid ? { sid: { lt: options.sid } } : {}),
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { sid: 'desc' },
|
||||
take: options?.take ?? 100,
|
||||
})) as Comment[];
|
||||
|
||||
const replies = (await this.db.reply.findMany({
|
||||
where: {
|
||||
commentId: { in: comments.map(comment => comment.id) },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { sid: 'asc' },
|
||||
})) as Reply[];
|
||||
|
||||
const replyMap = new Map<string, Reply[]>();
|
||||
for (const reply of replies) {
|
||||
const items = replyMap.get(reply.commentId) ?? [];
|
||||
items.push(reply);
|
||||
replyMap.set(reply.commentId, items);
|
||||
}
|
||||
|
||||
const commentWithReplies = comments.map(comment => ({
|
||||
...comment,
|
||||
replies: replyMap.get(comment.id) ?? [],
|
||||
}));
|
||||
|
||||
return commentWithReplies;
|
||||
}
|
||||
|
||||
async listChanges(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
options?: {
|
||||
commentUpdatedAt?: Date;
|
||||
replyUpdatedAt?: Date;
|
||||
take?: number;
|
||||
}
|
||||
): Promise<CommentChange[]> {
|
||||
const take = options?.take ?? 10000;
|
||||
const comments = (await this.db.comment.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
...(options?.commentUpdatedAt
|
||||
? { updatedAt: { gt: options.commentUpdatedAt } }
|
||||
: {}),
|
||||
},
|
||||
take,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
})) as Comment[];
|
||||
|
||||
const replies = (await this.db.reply.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
...(options?.replyUpdatedAt
|
||||
? { updatedAt: { gt: options.replyUpdatedAt } }
|
||||
: {}),
|
||||
},
|
||||
take,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
})) as Reply[];
|
||||
|
||||
const changes: CommentChange[] = [];
|
||||
for (const comment of comments) {
|
||||
if (comment.deletedAt) {
|
||||
changes.push({
|
||||
action: CommentChangeAction.delete,
|
||||
id: comment.id,
|
||||
item: {
|
||||
deletedAt: comment.deletedAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
changes.push({
|
||||
action: CommentChangeAction.update,
|
||||
id: comment.id,
|
||||
item: comment,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const reply of replies) {
|
||||
if (reply.deletedAt) {
|
||||
changes.push({
|
||||
action: CommentChangeAction.delete,
|
||||
id: reply.id,
|
||||
commentId: reply.commentId,
|
||||
item: {
|
||||
deletedAt: reply.deletedAt,
|
||||
updatedAt: reply.updatedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
changes.push({
|
||||
action: CommentChangeAction.update,
|
||||
id: reply.id,
|
||||
commentId: reply.commentId,
|
||||
item: reply,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Reply
|
||||
|
||||
/**
|
||||
* Reply to a comment
|
||||
* @param input - The reply create input
|
||||
* @returns The created reply
|
||||
*/
|
||||
async createReply(input: ReplyCreate) {
|
||||
const data = ReplyCreateSchema.parse(input);
|
||||
// find comment
|
||||
const comment = await this.get(data.commentId);
|
||||
if (!comment) {
|
||||
throw new CommentNotFound();
|
||||
}
|
||||
|
||||
return (await this.db.reply.create({
|
||||
data: {
|
||||
...data,
|
||||
workspaceId: comment.workspaceId,
|
||||
docId: comment.docId,
|
||||
},
|
||||
})) as Reply;
|
||||
}
|
||||
|
||||
async getReply(id: string) {
|
||||
return (await this.db.reply.findUnique({
|
||||
where: { id, deletedAt: null },
|
||||
})) as Reply | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a reply content
|
||||
* @param input - The reply update input
|
||||
* @returns The updated reply
|
||||
*/
|
||||
async updateReply(input: ReplyUpdate) {
|
||||
const data = ReplyUpdateSchema.parse(input);
|
||||
return await this.db.reply.update({
|
||||
where: { id: data.id, deletedAt: null },
|
||||
data: { content: data.content },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a reply
|
||||
* @param id - The id of the reply
|
||||
* @returns The deleted reply
|
||||
*/
|
||||
async deleteReply(id: string) {
|
||||
await this.db.reply.update({
|
||||
where: { id, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
this.logger.log(`Reply ${id} deleted`);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OneDay, OneGB, OneMB } from '../../base';
|
||||
import { Due, OneGB, OneMB } from '../../base';
|
||||
|
||||
const UserPlanQuotaConfig = z.object({
|
||||
// quota name
|
||||
@@ -104,7 +104,7 @@ export const FeatureConfigs: {
|
||||
blobLimit: 10 * OneMB,
|
||||
businessBlobLimit: 100 * OneMB,
|
||||
storageQuota: 10 * OneGB,
|
||||
historyPeriod: 7 * OneDay,
|
||||
historyPeriod: Due.ms('7d'),
|
||||
memberLimit: 3,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
@@ -116,7 +116,7 @@ export const FeatureConfigs: {
|
||||
name: 'Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
historyPeriod: Due.ms('30d'),
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
@@ -128,7 +128,7 @@ export const FeatureConfigs: {
|
||||
name: 'Lifetime Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 1024 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
historyPeriod: Due.ms('30d'),
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export const FeatureConfigs: {
|
||||
blobLimit: 500 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
seatQuota: 20 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
historyPeriod: Due.ms('30d'),
|
||||
memberLimit: 1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ApplyType } from '../base';
|
||||
import { CommentModel } from './comment';
|
||||
import { CommentAttachmentModel } from './comment-attachment';
|
||||
import { AppConfigModel } from './config';
|
||||
import { CopilotContextModel } from './copilot-context';
|
||||
import { CopilotJobModel } from './copilot-job';
|
||||
@@ -50,8 +48,6 @@ const MODELS = {
|
||||
copilotWorkspace: CopilotWorkspaceConfigModel,
|
||||
copilotJob: CopilotJobModel,
|
||||
appConfig: AppConfigModel,
|
||||
comment: CommentModel,
|
||||
commentAttachment: CommentAttachmentModel,
|
||||
};
|
||||
|
||||
type ModelsType = {
|
||||
@@ -103,8 +99,6 @@ const ModelsSymbolProvider: ExistingProvider = {
|
||||
})
|
||||
export class ModelsModule {}
|
||||
|
||||
export * from './comment';
|
||||
export * from './comment-attachment';
|
||||
export * from './common';
|
||||
export * from './copilot-context';
|
||||
export * from './copilot-job';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PaginationInput } from '../base';
|
||||
import { Due, PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import { DocMode } from './common';
|
||||
|
||||
@@ -15,8 +15,6 @@ export { NotificationLevel, NotificationType };
|
||||
export type { Notification };
|
||||
|
||||
// #region input
|
||||
|
||||
export const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
const IdSchema = z.string().trim().min(1).max(100);
|
||||
|
||||
export const BaseNotificationCreateSchema = z.object({
|
||||
@@ -237,7 +235,7 @@ export class NotificationModel extends BaseModel {
|
||||
async cleanExpiredNotifications() {
|
||||
const { count } = await this.db.notification.deleteMany({
|
||||
// delete notifications that are older than one year
|
||||
where: { createdAt: { lte: new Date(Date.now() - ONE_YEAR) } },
|
||||
where: { createdAt: { lte: Due.before('1y') } },
|
||||
});
|
||||
if (count > 0) {
|
||||
this.logger.log(`Deleted ${count} expired notifications`);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type UserSession,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { Config } from '../base';
|
||||
import { Config, Due, Duration } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export type { Session, UserSession };
|
||||
@@ -46,7 +46,7 @@ export class SessionModel extends BaseModel {
|
||||
async createOrRefreshUserSession(
|
||||
userId: string,
|
||||
sessionId?: string,
|
||||
ttl = this.config.auth.session.ttl
|
||||
ttl: Duration = this.config.auth.session.ttl
|
||||
) {
|
||||
// check whether given session is valid
|
||||
if (sessionId) {
|
||||
@@ -66,7 +66,7 @@ export class SessionModel extends BaseModel {
|
||||
sessionId = session.id;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
const expiresAt = Due.after(ttl);
|
||||
return await this.db.userSession.upsert({
|
||||
where: {
|
||||
sessionId_userId: {
|
||||
@@ -87,19 +87,17 @@ export class SessionModel extends BaseModel {
|
||||
|
||||
async refreshUserSessionIfNeeded(
|
||||
userSession: UserSession,
|
||||
ttr = this.config.auth.session.ttr
|
||||
ttr: Duration = this.config.auth.session.ttr
|
||||
): Promise<Date | undefined> {
|
||||
if (
|
||||
userSession.expiresAt &&
|
||||
userSession.expiresAt.getTime() - Date.now() > ttr * 1000
|
||||
Due.before(ttr, userSession.expiresAt) > new Date()
|
||||
) {
|
||||
// no need to refresh
|
||||
return;
|
||||
}
|
||||
|
||||
const newExpiresAt = new Date(
|
||||
Date.now() + this.config.auth.session.ttl * 1000
|
||||
);
|
||||
const newExpiresAt = Due.after(this.config.auth.session.ttl);
|
||||
await this.db.userSession.update({
|
||||
where: {
|
||||
id: userSession.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type VerificationToken } from '@prisma/client';
|
||||
|
||||
import { Due, Duration } from '../base';
|
||||
import { CryptoHelper } from '../base/helpers';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
@@ -25,18 +26,14 @@ export class VerificationTokenModel extends BaseModel {
|
||||
/**
|
||||
* create token by type and credential (optional) with ttl in seconds (default 30 minutes)
|
||||
*/
|
||||
async create(
|
||||
type: TokenType,
|
||||
credential?: string,
|
||||
ttlInSec: number = 30 * 60
|
||||
) {
|
||||
async create(type: TokenType, credential?: string, ttl: Duration = '30m') {
|
||||
const plaintextToken = randomUUID();
|
||||
const { token } = await this.db.verificationToken.create({
|
||||
data: {
|
||||
type,
|
||||
token: plaintextToken,
|
||||
credential,
|
||||
expiresAt: new Date(Date.now() + ttlInSec * 1000),
|
||||
expiresAt: Due.after(ttl),
|
||||
},
|
||||
});
|
||||
return this.crypto.encrypt(token);
|
||||
|
||||
@@ -56,26 +56,13 @@ export class CaptchaService {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const outcome = (await result.json()) as {
|
||||
success: boolean;
|
||||
hostname: string;
|
||||
};
|
||||
const outcome: any = await result.json();
|
||||
|
||||
if (!outcome.success) return false;
|
||||
|
||||
// skip hostname check in dev mode
|
||||
if (env.dev) return true;
|
||||
|
||||
// check if the hostname is in the hosts
|
||||
if (this.config.server.hosts.includes(outcome.hostname)) return true;
|
||||
|
||||
// check if the hostname is in the host
|
||||
if (this.config.server.host === outcome.hostname) return true;
|
||||
|
||||
this.logger.warn(
|
||||
`Captcha verification failed for hostname: ${outcome.hostname}`
|
||||
return (
|
||||
!!outcome.success &&
|
||||
// skip hostname check in dev mode
|
||||
(env.dev || outcome.hostname === this.config.server.host)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async verifyChallengeResponse(response: any, resource: string) {
|
||||
@@ -91,7 +78,7 @@ export class CaptchaService {
|
||||
const challenge = await this.models.verificationToken.create(
|
||||
TokenType.Challenge,
|
||||
resource,
|
||||
5 * 60
|
||||
'5m'
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from './providers/anthropic';
|
||||
import type { FalConfig } from './providers/fal';
|
||||
import { GeminiGenerativeConfig, GeminiVertexConfig } from './providers/gemini';
|
||||
import { MorphConfig } from './providers/morph';
|
||||
import { OpenAIConfig } from './providers/openai';
|
||||
import { PerplexityConfig } from './providers/perplexity';
|
||||
import { VertexSchema } from './providers/types';
|
||||
@@ -32,7 +31,6 @@ declare global {
|
||||
perplexity: ConfigItem<PerplexityConfig>;
|
||||
anthropic: ConfigItem<AnthropicOfficialConfig>;
|
||||
anthropicVertex: ConfigItem<AnthropicVertexConfig>;
|
||||
morph: ConfigItem<MorphConfig>;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -84,10 +82,6 @@ defineModuleConfig('copilot', {
|
||||
default: {},
|
||||
schema: VertexSchema,
|
||||
},
|
||||
'providers.morph': {
|
||||
desc: 'The config for the morph provider.',
|
||||
default: {},
|
||||
},
|
||||
unsplash: {
|
||||
desc: 'The config for the unsplash key.',
|
||||
default: {
|
||||
|
||||
@@ -197,52 +197,34 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
async matchWorkspaceAll(
|
||||
workspaceId: string,
|
||||
content: string,
|
||||
topK: number,
|
||||
topK: number = 5,
|
||||
signal?: AbortSignal,
|
||||
threshold: number = 0.8,
|
||||
docIds?: string[],
|
||||
scopedThreshold: number = 0.85
|
||||
threshold: number = 0.5
|
||||
) {
|
||||
if (!this.embeddingClient) return [];
|
||||
const embedding = await this.embeddingClient.getEmbedding(content, signal);
|
||||
if (!embedding) return [];
|
||||
|
||||
const [fileChunks, workspaceChunks, scopedWorkspaceChunks] =
|
||||
await Promise.all([
|
||||
this.models.copilotWorkspace.matchFileEmbedding(
|
||||
workspaceId,
|
||||
embedding,
|
||||
topK * 2,
|
||||
threshold
|
||||
),
|
||||
const [fileChunks, workspaceChunks] = await Promise.all([
|
||||
this.models.copilotWorkspace.matchFileEmbedding(
|
||||
workspaceId,
|
||||
embedding,
|
||||
topK * 2,
|
||||
threshold
|
||||
),
|
||||
this.models.copilotContext.matchWorkspaceEmbedding(
|
||||
embedding,
|
||||
workspaceId,
|
||||
topK * 2,
|
||||
threshold
|
||||
),
|
||||
]);
|
||||
|
||||
this.models.copilotContext.matchWorkspaceEmbedding(
|
||||
embedding,
|
||||
workspaceId,
|
||||
topK * 2,
|
||||
threshold
|
||||
),
|
||||
docIds
|
||||
? this.models.copilotContext.matchWorkspaceEmbedding(
|
||||
embedding,
|
||||
workspaceId,
|
||||
topK * 2,
|
||||
scopedThreshold,
|
||||
docIds
|
||||
)
|
||||
: null,
|
||||
]);
|
||||
|
||||
if (
|
||||
!fileChunks.length &&
|
||||
!workspaceChunks.length &&
|
||||
!scopedWorkspaceChunks?.length
|
||||
)
|
||||
return [];
|
||||
if (!fileChunks.length && !workspaceChunks.length) return [];
|
||||
|
||||
return await this.embeddingClient.reRank(
|
||||
content,
|
||||
[...fileChunks, ...workspaceChunks, ...(scopedWorkspaceChunks || [])],
|
||||
[...fileChunks, ...workspaceChunks],
|
||||
topK,
|
||||
signal
|
||||
);
|
||||
|
||||
@@ -257,7 +257,6 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
@@ -312,7 +311,6 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
@@ -386,7 +384,6 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
@@ -466,7 +463,6 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
})
|
||||
).pipe(
|
||||
@@ -590,7 +586,6 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
seed: this.parseNumber(params.seed),
|
||||
signal: this.getSignal(req),
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
EMBEDDING_DIMENSIONS,
|
||||
EmbeddingClient,
|
||||
getReRankSchema,
|
||||
type ReRankResult,
|
||||
} from './types';
|
||||
|
||||
@@ -80,9 +81,9 @@ class ProductionEmbeddingClient extends EmbeddingClient {
|
||||
}
|
||||
|
||||
private getTargetId<T extends ChunkSimilarity>(embedding: T) {
|
||||
return 'docId' in embedding && typeof embedding.docId === 'string'
|
||||
return 'docId' in embedding
|
||||
? embedding.docId
|
||||
: 'fileId' in embedding && typeof embedding.fileId === 'string'
|
||||
: 'fileId' in embedding
|
||||
? embedding.fileId
|
||||
: '';
|
||||
}
|
||||
@@ -101,19 +102,24 @@ class ProductionEmbeddingClient extends EmbeddingClient {
|
||||
throw new CopilotPromptNotFound({ name: RERANK_PROMPT });
|
||||
}
|
||||
const provider = await this.getProvider({ modelId: prompt.model });
|
||||
const schema = getReRankSchema(embeddings.length);
|
||||
|
||||
const ranks = await provider.rerank(
|
||||
const ranks = await provider.structure(
|
||||
{ modelId: prompt.model },
|
||||
embeddings.map(e => prompt.finish({ query, doc: e.content })),
|
||||
{ signal }
|
||||
prompt.finish({
|
||||
query,
|
||||
results: embeddings.map(e => ({
|
||||
targetId: this.getTargetId(e),
|
||||
chunk: e.chunk,
|
||||
content: e.content,
|
||||
})),
|
||||
schema,
|
||||
}),
|
||||
{ maxRetries: 3, signal }
|
||||
);
|
||||
|
||||
try {
|
||||
return ranks.map((score, i) => ({
|
||||
chunk: embeddings[i].content,
|
||||
targetId: this.getTargetId(embeddings[i]),
|
||||
score,
|
||||
}));
|
||||
return schema.parse(JSON.parse(ranks)).ranks;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse rerank results', error);
|
||||
// silent error, will fallback to default sorting in parent method
|
||||
@@ -170,9 +176,9 @@ class ProductionEmbeddingClient extends EmbeddingClient {
|
||||
|
||||
const highConfidenceChunks = ranks
|
||||
.flat()
|
||||
.toSorted((a, b) => b.score - a.score)
|
||||
.filter(r => r.score > 5)
|
||||
.map(r => chunks[`${r.targetId}:${r.chunk}`])
|
||||
.toSorted((a, b) => b.scores.score - a.scores.score)
|
||||
.filter(r => r.scores.score > 5)
|
||||
.map(r => chunks[`${r.scores.targetId}:${r.scores.chunk}`])
|
||||
.filter(Boolean);
|
||||
|
||||
this.logger.verbose(
|
||||
|
||||
@@ -177,6 +177,11 @@ export abstract class EmbeddingClient {
|
||||
|
||||
const ReRankItemSchema = z.object({
|
||||
scores: z.object({
|
||||
reason: z
|
||||
.string()
|
||||
.describe(
|
||||
'Think step by step, describe in 20 words the reason for giving this score.'
|
||||
),
|
||||
chunk: z.string().describe('The chunk index of the search result.'),
|
||||
targetId: z.string().describe('The id of the target.'),
|
||||
score: z
|
||||
@@ -189,4 +194,11 @@ const ReRankItemSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export type ReRankResult = z.infer<typeof ReRankItemSchema>['scores'][];
|
||||
export const getReRankSchema = (size: number) =>
|
||||
z.object({
|
||||
ranks: ReRankItemSchema.array().describe(
|
||||
`A array of scores. Make sure to score all ${size} results.`
|
||||
),
|
||||
});
|
||||
|
||||
export type ReRankResult = z.infer<ReturnType<typeof getReRankSchema>>['ranks'];
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SessionCache } from '../../base';
|
||||
import { SubmittedMessage, SubmittedMessageSchema } from './types';
|
||||
|
||||
const CHAT_MESSAGE_KEY = 'chat-message';
|
||||
const CHAT_MESSAGE_TTL = 3600 * 1 * 1000; // 1 hours
|
||||
|
||||
@Injectable()
|
||||
export class ChatMessageCache {
|
||||
@@ -20,7 +19,7 @@ export class ChatMessageCache {
|
||||
const parsedMessage = SubmittedMessageSchema.parse(message);
|
||||
const id = randomUUID();
|
||||
await this.cache.set(`${CHAT_MESSAGE_KEY}:${id}`, parsedMessage, {
|
||||
ttl: CHAT_MESSAGE_TTL,
|
||||
ttl: '1h',
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -342,11 +342,57 @@ Convert a multi-speaker audio recording into a structured JSON format by transcr
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Judge whether the Document meets the requirements based on the Query and the Instruct provided. The answer must be "yes" or "no".`,
|
||||
content: `Evaluate and rank search results based on their relevance and quality to the given query by assigning a score from 1 to 10, where 10 denotes the highest relevance.
|
||||
|
||||
Consider various factors such as content alignment with the query, source credibility, timeliness, and user intent.
|
||||
|
||||
# Steps
|
||||
|
||||
1. **Read the Query**: Understand the main intent and specific details of the search query.
|
||||
2. **Review Each Result**:
|
||||
- Analyze the content's relevance to the query.
|
||||
- Assess the credibility of the source or website.
|
||||
- Consider the timeliness of the information, ensuring it's current and relevant.
|
||||
- Evaluate the alignment with potential user intent based on the query.
|
||||
3. **Scoring**:
|
||||
- Assign a score from 1 to 10 based on the overall relevance and quality, with 10 being the most relevant.
|
||||
- Each chunk returns a score and should not be mixed together.
|
||||
|
||||
# Output Format
|
||||
|
||||
Return a JSON object for each result in the following format in raw:
|
||||
{
|
||||
"scores": [
|
||||
{
|
||||
"reason": "[Reasoning behind the score in 20 words]",
|
||||
"chunk": "[chunk]",
|
||||
"targetId": "[targetId]",
|
||||
"score": [1-10]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Notes
|
||||
|
||||
- Be aware of the potential biases or inaccuracies in the sources.
|
||||
- Consider if the content is comprehensive and directly answers the query.
|
||||
- Pay attention to the nuances of user intent that might influence relevance.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<Instruct>: Given a web search query, retrieve relevant passages that answer the query\n<Query>: {query}\n<Document>: {doc}`,
|
||||
content: `
|
||||
<query>{{query}}</query>
|
||||
<results>
|
||||
{{#results}}
|
||||
<result>
|
||||
<targetId>{{targetId}}</targetId>
|
||||
<chunk>{{chunk}}</chunk>
|
||||
<content>
|
||||
{{content}}
|
||||
</content>
|
||||
</result>
|
||||
{{/results}}
|
||||
</results>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1624,11 +1670,6 @@ Your mission is to do your utmost to help users leverage AFFiNE's capabilities f
|
||||
AFFiNE is developed by Toeverything Pte. Ltd., a Singapore-registered company with a diverse international team. The company has also open-sourced BlockSuite and OctoBase to support the creation of tools similar to AFFiNE. The name "AFFiNE" is inspired by the concept of affine transformation, as blocks within AFFiNE can move freely across page, edgeless, and database modes. Currently, the AFFiNE team consists of 25 members and is an engineer-driven open-source company.
|
||||
|
||||
<response_guide>
|
||||
<tool_usage_guide>
|
||||
- When searching for information, prioritize searching the user's Workspace information.
|
||||
- Depending on the complexity of the question and the information returned by the search tools, you can call different tools multiple times to search.
|
||||
</tool_usage_guide>
|
||||
|
||||
<real_world_info>
|
||||
Today is: {{affine::date}}.
|
||||
User's preferred language is {{affine::language}}.
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from './anthropic';
|
||||
import { FalProvider } from './fal';
|
||||
import { GeminiGenerativeProvider, GeminiVertexProvider } from './gemini';
|
||||
import { MorphProvider } from './morph';
|
||||
import { OpenAIProvider } from './openai';
|
||||
import { PerplexityProvider } from './perplexity';
|
||||
|
||||
@@ -16,7 +15,6 @@ export const CopilotProviders = [
|
||||
PerplexityProvider,
|
||||
AnthropicOfficialProvider,
|
||||
AnthropicVertexProvider,
|
||||
MorphProvider,
|
||||
];
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import {
|
||||
createOpenAICompatible,
|
||||
OpenAICompatibleProvider as VercelOpenAICompatibleProvider,
|
||||
} from '@ai-sdk/openai-compatible';
|
||||
import { AISDKError, generateText, streamText } from 'ai';
|
||||
|
||||
import {
|
||||
CopilotProviderSideError,
|
||||
metrics,
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { CopilotProvider } from './provider';
|
||||
import type {
|
||||
CopilotChatOptions,
|
||||
ModelConditions,
|
||||
PromptMessage,
|
||||
} from './types';
|
||||
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
|
||||
import { chatToGPTMessage, CitationParser, TextStreamParser } from './utils';
|
||||
|
||||
export const DEFAULT_DIMENSIONS = 256;
|
||||
|
||||
export type MorphConfig = {
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export class MorphProvider extends CopilotProvider<MorphConfig> {
|
||||
readonly type = CopilotProviderType.Morph;
|
||||
|
||||
readonly models = [
|
||||
{
|
||||
id: 'morph-v2',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
output: [ModelOutputType.Text],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
#instance!: VercelOpenAICompatibleProvider;
|
||||
|
||||
override configured(): boolean {
|
||||
return !!this.config.apiKey;
|
||||
}
|
||||
|
||||
protected override setup() {
|
||||
super.setup();
|
||||
this.#instance = createOpenAICompatible({
|
||||
name: this.type,
|
||||
apiKey: this.config.apiKey,
|
||||
baseURL: 'https://api.morphllm.com/v1',
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(e: any) {
|
||||
if (e instanceof UserFriendlyError) {
|
||||
return e;
|
||||
} else if (e instanceof AISDKError) {
|
||||
return new CopilotProviderSideError({
|
||||
provider: this.type,
|
||||
kind: e.name || 'unknown',
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
return new CopilotProviderSideError({
|
||||
provider: this.type,
|
||||
kind: 'unexpected_response',
|
||||
message: e?.message || 'Unexpected morph response',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async text(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
try {
|
||||
metrics.ai.counter('chat_text_calls').add(1, { model: model.id });
|
||||
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
|
||||
const modelInstance = this.#instance(model.id);
|
||||
|
||||
const { text } = await generateText({
|
||||
model: modelInstance,
|
||||
system,
|
||||
messages: msgs,
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
} catch (e: any) {
|
||||
metrics.ai.counter('chat_text_errors').add(1, { model: model.id });
|
||||
throw this.handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async *streamText(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
try {
|
||||
metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id });
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
|
||||
const modelInstance = this.#instance(model.id);
|
||||
|
||||
const { fullStream } = streamText({
|
||||
model: modelInstance,
|
||||
system,
|
||||
messages: msgs,
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
const citationParser = new CitationParser();
|
||||
const textParser = new TextStreamParser();
|
||||
for await (const chunk of fullStream) {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta': {
|
||||
let result = textParser.parse(chunk);
|
||||
result = citationParser.parse(result);
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
case 'finish': {
|
||||
const result = citationParser.end();
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
yield textParser.parse(chunk);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
await fullStream.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
metrics.ai.counter('chat_text_stream_errors').add(1, { model: model.id });
|
||||
throw this.handleError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,60 +440,6 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
override async rerank(
|
||||
cond: ModelConditions,
|
||||
chunkMessages: PromptMessage[][],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<number[]> {
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Text };
|
||||
await this.checkParams({ messages: [], cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
const instance = this.#instance.responses(model.id);
|
||||
|
||||
const scores = await Promise.all(
|
||||
chunkMessages.map(async messages => {
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
|
||||
const { logprobs } = await generateText({
|
||||
model: instance,
|
||||
system,
|
||||
messages: msgs,
|
||||
temperature: 0,
|
||||
maxTokens: 1,
|
||||
providerOptions: {
|
||||
openai: {
|
||||
...this.getOpenAIOptions(options, model.id),
|
||||
// get the log probability of "yes"/"no"
|
||||
logprobs: 2,
|
||||
},
|
||||
},
|
||||
maxSteps: 1,
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
const top = (logprobs?.[0]?.topLogprobs ?? []).reduce(
|
||||
(acc, item) => {
|
||||
acc[item.token] = item.logprob;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
// OpenAI often includes a leading space, so try matching both ' yes' and 'yes'
|
||||
const logYes = top[' yes'] ?? top['yes'] ?? Number.NEGATIVE_INFINITY;
|
||||
const logNo = top[' no'] ?? top['no'] ?? Number.NEGATIVE_INFINITY;
|
||||
|
||||
const pYes = Math.exp(logYes);
|
||||
const pNo = Math.exp(logNo);
|
||||
const prob = pYes + pNo === 0 ? 0 : pYes / (pYes + pNo);
|
||||
|
||||
return prob;
|
||||
})
|
||||
);
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private async getFullStream(
|
||||
model: CopilotProviderModel,
|
||||
messages: PromptMessage[],
|
||||
|
||||
@@ -9,19 +9,13 @@ import {
|
||||
CopilotProviderNotSupported,
|
||||
OnEvent,
|
||||
} from '../../../base';
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { Models } from '../../../models';
|
||||
import { IndexerService } from '../../indexer';
|
||||
import { CopilotContextService } from '../context';
|
||||
import {
|
||||
buildContentGetter,
|
||||
buildDocContentGetter,
|
||||
buildDocKeywordSearchGetter,
|
||||
buildDocSearchGetter,
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
createDocReadTool,
|
||||
createDocSemanticSearchTool,
|
||||
createExaCrawlTool,
|
||||
createExaSearchTool,
|
||||
@@ -135,8 +129,6 @@ export abstract class CopilotProvider<C = any> {
|
||||
const tools: ToolSet = {};
|
||||
if (options?.tools?.length) {
|
||||
this.logger.debug(`getTools: ${JSON.stringify(options.tools)}`);
|
||||
const ac = this.moduleRef.get(AccessController, { strict: false });
|
||||
|
||||
for (const tool of options.tools) {
|
||||
const toolDef = this.getProviderSpecificTools(tool, model);
|
||||
if (toolDef) {
|
||||
@@ -144,24 +136,12 @@ export abstract class CopilotProvider<C = any> {
|
||||
continue;
|
||||
}
|
||||
switch (tool) {
|
||||
case 'docEdit': {
|
||||
const doc = this.moduleRef.get(DocReader, { strict: false });
|
||||
const getDocContent = buildContentGetter(ac, doc);
|
||||
tools.doc_edit = createDocEditTool(
|
||||
this.factory,
|
||||
getDocContent.bind(null, options)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'docSemanticSearch': {
|
||||
const ac = this.moduleRef.get(AccessController, { strict: false });
|
||||
const context = this.moduleRef.get(CopilotContextService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const docContext = options.session
|
||||
? await context.getBySessionId(options.session)
|
||||
: null;
|
||||
const searchDocs = buildDocSearchGetter(ac, context, docContext);
|
||||
const searchDocs = buildDocSearchGetter(ac, context);
|
||||
tools.doc_semantic_search = createDocSemanticSearchTool(
|
||||
searchDocs.bind(null, options)
|
||||
);
|
||||
@@ -185,14 +165,6 @@ export abstract class CopilotProvider<C = any> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'docRead': {
|
||||
const ac = this.moduleRef.get(AccessController, { strict: false });
|
||||
const models = this.moduleRef.get(Models, { strict: false });
|
||||
const docReader = this.moduleRef.get(DocReader, { strict: false });
|
||||
const getDoc = buildDocContentGetter(ac, docReader, models);
|
||||
tools.doc_read = createDocReadTool(getDoc.bind(null, options));
|
||||
break;
|
||||
}
|
||||
case 'webSearch': {
|
||||
tools.web_search_exa = createExaSearchTool(this.AFFiNEConfig);
|
||||
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
|
||||
@@ -319,15 +291,4 @@ export abstract class CopilotProvider<C = any> {
|
||||
kind: 'embedding',
|
||||
});
|
||||
}
|
||||
|
||||
async rerank(
|
||||
_model: ModelConditions,
|
||||
_messages: PromptMessage[][],
|
||||
_options?: CopilotChatOptions
|
||||
): Promise<number[]> {
|
||||
throw new CopilotProviderNotSupported({
|
||||
provider: this.type,
|
||||
kind: 'rerank',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export enum CopilotProviderType {
|
||||
GeminiVertex = 'geminiVertex',
|
||||
OpenAI = 'openai',
|
||||
Perplexity = 'perplexity',
|
||||
Morph = 'morph',
|
||||
}
|
||||
|
||||
export const CopilotProviderSchema = z.object({
|
||||
@@ -162,7 +161,6 @@ export type StreamObject = z.infer<typeof StreamObjectSchema>;
|
||||
const CopilotProviderOptionsSchema = z.object({
|
||||
signal: z.instanceof(AbortSignal).optional(),
|
||||
user: z.string().optional(),
|
||||
session: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
import { ZodType } from 'zod';
|
||||
|
||||
import {
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
createDocReadTool,
|
||||
createDocSemanticSearchTool,
|
||||
createExaCrawlTool,
|
||||
createExaSearchTool,
|
||||
@@ -384,10 +382,8 @@ export class CitationParser {
|
||||
}
|
||||
|
||||
export interface CustomAITools extends ToolSet {
|
||||
doc_edit: ReturnType<typeof createDocEditTool>;
|
||||
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
|
||||
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
|
||||
doc_read: ReturnType<typeof createDocReadTool>;
|
||||
web_search_exa: ReturnType<typeof createExaSearchTool>;
|
||||
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
|
||||
}
|
||||
@@ -453,10 +449,6 @@ export class TextStreamParser {
|
||||
result += `\nSearching the keyword "${chunk.args.query}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'doc_read': {
|
||||
result += `\nReading the doc "${chunk.args.doc_id}"\n`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result = this.markAsCallout(result);
|
||||
break;
|
||||
@@ -467,12 +459,6 @@ export class TextStreamParser {
|
||||
);
|
||||
result = this.addPrefix(result);
|
||||
switch (chunk.toolName) {
|
||||
case 'doc_edit': {
|
||||
if (chunk.result && typeof chunk.result === 'object') {
|
||||
result += `\n${chunk.result.result}\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'doc_semantic_search': {
|
||||
if (Array.isArray(chunk.result)) {
|
||||
result += `\nFound ${chunk.result.length} document${chunk.result.length !== 1 ? 's' : ''} related to “${chunk.args.query}”.\n`;
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import type { CopilotChatOptions, CopilotProviderFactory } from '../providers';
|
||||
|
||||
export const buildContentGetter = (ac: AccessController, doc: DocReader) => {
|
||||
const getDocContent = async (options: CopilotChatOptions, docId?: string) => {
|
||||
if (!options || !docId || !options.user || !options.workspace) {
|
||||
return undefined;
|
||||
}
|
||||
const canAccess = await ac
|
||||
.user(options.user)
|
||||
.workspace(options.workspace)
|
||||
.doc(docId)
|
||||
.can('Doc.Read');
|
||||
if (!canAccess) return undefined;
|
||||
const content = await doc.getFullDocContent(options.workspace, docId);
|
||||
return content?.summary.trim() || undefined;
|
||||
};
|
||||
return getDocContent;
|
||||
};
|
||||
|
||||
export const createDocEditTool = (
|
||||
factory: CopilotProviderFactory,
|
||||
getContent: (targetId?: string) => Promise<string | undefined>
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
"Use this tool to propose an edit to an existing doc.\n\nThis will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\nWhen writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.\n\nYou should bias towards repeating as few lines of the original doc as possible to convey the change.\nEach edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity.\nIf you plan on deleting a section, you must provide surrounding context to indicate the deletion.\nDO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence.\n\nYou should specify the following arguments before the others: [target_id], [origin_content]",
|
||||
parameters: z.object({
|
||||
doc_id: z
|
||||
.string()
|
||||
.describe(
|
||||
'The target doc to modify. Always specify the target doc as the first argument. If the content to be modified does not include a specific document, the full text should be provided through origin_content.'
|
||||
)
|
||||
.optional(),
|
||||
origin_content: z
|
||||
.string()
|
||||
.describe(
|
||||
'The original content of the doc you are editing. If the original text is from a specific document, the target_id should be provided instead of this parameter.'
|
||||
)
|
||||
.optional(),
|
||||
instructions: z
|
||||
.string()
|
||||
.describe(
|
||||
'A single sentence instruction describing what you are going to do for the sketched edit. This is used to assist the less intelligent model in applying the edit. Please use the first person to describe what you are going to do. Dont repeat what you have said previously in normal messages. And use it to disambiguate uncertainty in the edit.'
|
||||
),
|
||||
code_edit: z
|
||||
.string()
|
||||
.describe(
|
||||
"Specify ONLY the precise lines of code that you wish to edit. NEVER specify or write out unchanged code. Instead, represent all unchanged code using the comment of the language you're editing in - example: // ... existing code ..."
|
||||
),
|
||||
}),
|
||||
execute: async ({ doc_id, origin_content, code_edit }) => {
|
||||
try {
|
||||
const provider = await factory.getProviderByModel('morph-v2');
|
||||
if (!provider) {
|
||||
return 'Editing docs is not supported';
|
||||
}
|
||||
|
||||
const content = origin_content || (await getContent(doc_id));
|
||||
if (!content) {
|
||||
return 'Doc not found or doc is empty';
|
||||
}
|
||||
const result = await provider.text({ modelId: 'morph-v2' }, [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<code>${content}</code>\n<update>${code_edit}</update>`,
|
||||
},
|
||||
]);
|
||||
return { result };
|
||||
} catch {
|
||||
return 'Failed to apply edit to the doc';
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { Models, publicUserSelect } from '../../../models';
|
||||
import type { CopilotChatOptions } from '../providers';
|
||||
import { toolError } from './error';
|
||||
|
||||
const logger = new Logger('DocReadTool');
|
||||
|
||||
export const buildDocContentGetter = (
|
||||
ac: AccessController,
|
||||
docReader: DocReader,
|
||||
models: Models
|
||||
) => {
|
||||
const getDoc = async (options: CopilotChatOptions, docId?: string) => {
|
||||
if (!options?.user || !options?.workspace || !docId) {
|
||||
return;
|
||||
}
|
||||
const canAccess = await ac
|
||||
.user(options.user)
|
||||
.workspace(options.workspace)
|
||||
.doc(docId)
|
||||
.can('Doc.Read');
|
||||
if (!canAccess) {
|
||||
logger.warn(
|
||||
`User ${options.user} does not have access to doc ${docId} in workspace ${options.workspace}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const docMeta = await models.doc.getSnapshot(options.workspace, docId, {
|
||||
select: {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdByUser: {
|
||||
select: publicUserSelect,
|
||||
},
|
||||
updatedByUser: {
|
||||
select: publicUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!docMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await docReader.getDocMarkdown(options.workspace, docId);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
title: content.title,
|
||||
markdown: content.markdown,
|
||||
createdAt: docMeta.createdAt,
|
||||
updatedAt: docMeta.updatedAt,
|
||||
createdByUser: docMeta.createdByUser,
|
||||
updatedByUser: docMeta.updatedByUser,
|
||||
};
|
||||
};
|
||||
return getDoc;
|
||||
};
|
||||
|
||||
export const createDocReadTool = (
|
||||
getDoc: (targetId?: string) => Promise<object | undefined>
|
||||
) => {
|
||||
return tool({
|
||||
description: 'Read the content of a doc in the current workspace',
|
||||
parameters: z.object({
|
||||
doc_id: z.string().describe('The target doc to read'),
|
||||
}),
|
||||
execute: async ({ doc_id }) => {
|
||||
try {
|
||||
const doc = await getDoc(doc_id);
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
return { ...doc };
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to read the doc ${doc_id}`, err);
|
||||
return toolError('Doc Read Failed', err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -4,20 +4,14 @@ import { z } from 'zod';
|
||||
import type { AccessController } from '../../../core/permission';
|
||||
import type { ChunkSimilarity } from '../../../models';
|
||||
import type { CopilotContextService } from '../context';
|
||||
import type { ContextSession } from '../context/session';
|
||||
import type { CopilotChatOptions } from '../providers';
|
||||
import { toolError } from './error';
|
||||
|
||||
export const buildDocSearchGetter = (
|
||||
ac: AccessController,
|
||||
context: CopilotContextService,
|
||||
docContext: ContextSession | null
|
||||
context: CopilotContextService
|
||||
) => {
|
||||
const searchDocs = async (
|
||||
options: CopilotChatOptions,
|
||||
query?: string,
|
||||
abortSignal?: AbortSignal
|
||||
) => {
|
||||
const searchDocs = async (options: CopilotChatOptions, query?: string) => {
|
||||
if (!options || !query?.trim() || !options.user || !options.workspace) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -26,11 +20,7 @@ export const buildDocSearchGetter = (
|
||||
.workspace(options.workspace)
|
||||
.can('Workspace.Read');
|
||||
if (!canAccess) return undefined;
|
||||
const [chunks, contextChunks] = await Promise.all([
|
||||
context.matchWorkspaceAll(options.workspace, query, 10, abortSignal),
|
||||
docContext?.matchFiles(query, 10, abortSignal) ?? [],
|
||||
]);
|
||||
|
||||
const chunks = await context.matchWorkspaceAll(options.workspace, query);
|
||||
const docChunks = await ac
|
||||
.user(options.user)
|
||||
.workspace(options.workspace)
|
||||
@@ -39,9 +29,6 @@ export const buildDocSearchGetter = (
|
||||
'Doc.Read'
|
||||
);
|
||||
const fileChunks = chunks.filter(c => 'fileId' in c);
|
||||
if (contextChunks.length) {
|
||||
fileChunks.push(...contextChunks);
|
||||
}
|
||||
if (!docChunks.length && !fileChunks.length) return undefined;
|
||||
return [...fileChunks, ...docChunks];
|
||||
};
|
||||
@@ -49,24 +36,17 @@ export const buildDocSearchGetter = (
|
||||
};
|
||||
|
||||
export const createDocSemanticSearchTool = (
|
||||
searchDocs: (
|
||||
query: string,
|
||||
abortSignal?: AbortSignal
|
||||
) => Promise<ChunkSimilarity[] | undefined>
|
||||
searchDocs: (query: string) => Promise<ChunkSimilarity[] | undefined>
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
'Semantic search for relevant documents in the current workspace',
|
||||
parameters: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
'The query statement to search for, e.g. "What is the capital of France?"'
|
||||
),
|
||||
query: z.string().describe('The query to search for.'),
|
||||
}),
|
||||
execute: async ({ query }, options) => {
|
||||
execute: async ({ query }) => {
|
||||
try {
|
||||
return await searchDocs(query, options.abortSignal);
|
||||
return await searchDocs(query);
|
||||
} catch (e: any) {
|
||||
return toolError('Doc Semantic Search Failed', e.message);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export * from './doc-edit';
|
||||
export * from './doc-keyword-search';
|
||||
export * from './doc-read';
|
||||
export * from './doc-semantic-search';
|
||||
export * from './error';
|
||||
export * from './exa-crawl';
|
||||
|
||||
@@ -6,7 +6,7 @@ import Sinon from 'sinon';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { JOB_SIGNAL } from '../../../base';
|
||||
import { Due, JOB_SIGNAL } from '../../../base';
|
||||
import { ConfigModule } from '../../../base/config';
|
||||
import { ServerConfigModule } from '../../../core/config';
|
||||
import { Models } from '../../../models';
|
||||
@@ -160,7 +160,7 @@ test('should not index workspace if it is not updated in 180 days', async t => {
|
||||
user,
|
||||
workspaceId: workspace.id,
|
||||
docId: workspace.id,
|
||||
updatedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000 - 1),
|
||||
updatedAt: Due.before('181d'),
|
||||
});
|
||||
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
|
||||
import { Config, Due, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
|
||||
import { readAllDocIdsFromWorkspaceSnapshot } from '../../core/utils/blocksuite';
|
||||
import { Models } from '../../models';
|
||||
import { IndexerService } from './service';
|
||||
@@ -182,8 +182,7 @@ export class IndexerJob {
|
||||
// ignore 180 days not updated workspaces
|
||||
if (
|
||||
!snapshotMeta?.updatedAt ||
|
||||
Date.now() - snapshotMeta.updatedAt.getTime() >
|
||||
180 * 24 * 60 * 60 * 1000
|
||||
snapshotMeta.updatedAt < Due.before('180d')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CryptoHelper,
|
||||
Due,
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
InvalidLicenseToActivate,
|
||||
@@ -337,7 +338,7 @@ export class LicenseService {
|
||||
const licenses = await this.db.installedLicense.findMany({
|
||||
where: {
|
||||
validatedAt: {
|
||||
lte: new Date(Date.now() - 1000 * 60 * 60 /* 1h */),
|
||||
lte: Due.before('1h'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ export class OAuthController {
|
||||
provider: rawState.provider,
|
||||
})
|
||||
);
|
||||
clientUrl.searchParams.set('server', this.url.requestOrigin);
|
||||
clientUrl.searchParams.set('server', this.url.origin);
|
||||
|
||||
return res.redirect(
|
||||
this.url.link('/open-app/url?', {
|
||||
|
||||
@@ -29,7 +29,7 @@ export class OAuthService {
|
||||
async saveOAuthState(state: OAuthState) {
|
||||
const token = randomUUID();
|
||||
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {
|
||||
ttl: 3600 * 3 * 1000 /* 3 hours */,
|
||||
ttl: '3h',
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
@@ -26,9 +26,6 @@ import {
|
||||
} from './utils';
|
||||
import { decodeWithCharset } from './utils/encoding';
|
||||
|
||||
// cache for 30 minutes
|
||||
const CACHE_TTL = 1000 * 60 * 30;
|
||||
|
||||
@Public()
|
||||
@UseNamedGuard('selfhost')
|
||||
@Controller('/api/worker')
|
||||
@@ -56,7 +53,7 @@ export class WorkerController {
|
||||
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
|
||||
throw new BadRequest('Invalid header');
|
||||
}
|
||||
const url = new URL(req.url, this.url.requestBaseUrl);
|
||||
const url = new URL(req.url, this.url.baseUrl);
|
||||
const imageURL = url.searchParams.get('url');
|
||||
if (!imageURL) {
|
||||
throw new BadRequest('Missing "url" parameter');
|
||||
@@ -98,7 +95,7 @@ export class WorkerController {
|
||||
if (contentType?.startsWith('image/')) {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
await this.cache.set(cachedUrl, buffer.toString('base64'), {
|
||||
ttl: CACHE_TTL,
|
||||
ttl: '30m',
|
||||
});
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
return resp
|
||||
@@ -118,7 +115,7 @@ export class WorkerController {
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
// rejected by server, cache a empty response
|
||||
await this.cache.set(cachedUrl, Buffer.from([]).toString('base64'), {
|
||||
ttl: CACHE_TTL,
|
||||
ttl: '30m',
|
||||
});
|
||||
}
|
||||
this.logger.error('Failed to fetch image', {
|
||||
@@ -302,7 +299,7 @@ export class WorkerController {
|
||||
responseSize: json.length,
|
||||
});
|
||||
|
||||
await this.cache.set(cachedUrl, res, { ttl: CACHE_TTL });
|
||||
await this.cache.set(cachedUrl, res, { ttl: '30m' });
|
||||
return resp
|
||||
.status(200)
|
||||
.header({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fixUrl, OriginRules } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkerService {
|
||||
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
|
||||
allowedOrigins: OriginRules = [this.url.origin];
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
@@ -18,7 +18,7 @@ export class WorkerService {
|
||||
...this.config.worker.allowedOrigin
|
||||
.map(u => fixUrl(u)?.origin as string)
|
||||
.filter(v => !!v),
|
||||
...this.url.allowedOrigins,
|
||||
this.url.origin,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -539,7 +539,6 @@ enum ErrorNames {
|
||||
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
|
||||
CAN_NOT_REVOKE_YOURSELF
|
||||
CAPTCHA_VERIFICATION_FAILED
|
||||
COMMENT_NOT_FOUND
|
||||
COPILOT_ACTION_TAKEN
|
||||
COPILOT_CONTEXT_FILE_NOT_SUPPORTED
|
||||
COPILOT_DOCS_NOT_FOUND
|
||||
@@ -629,7 +628,6 @@ enum ErrorNames {
|
||||
OWNER_CAN_NOT_LEAVE_WORKSPACE
|
||||
PASSWORD_REQUIRED
|
||||
QUERY_TOO_LONG
|
||||
REPLY_NOT_FOUND
|
||||
RUNTIME_CONFIG_NOT_FOUND
|
||||
SAME_EMAIL_PROVIDED
|
||||
SAME_SUBSCRIPTION_RECURRING
|
||||
@@ -1585,9 +1583,6 @@ enum SearchTable {
|
||||
}
|
||||
|
||||
type ServerConfigType {
|
||||
"""Whether allow guest users to create demo workspaces."""
|
||||
allowGuestDemoWorkspace: Boolean!
|
||||
|
||||
"""fetch latest available upgradable release of server"""
|
||||
availableUpgrade: ReleaseVersionType
|
||||
|
||||
|
||||
@@ -66,5 +66,5 @@ export async function run() {
|
||||
|
||||
logger.log(`AFFiNE Server is running in [${env.DEPLOYMENT_TYPE}] mode`);
|
||||
logger.log(`Listening on http://${listeningHost}:${config.server.port}`);
|
||||
logger.log(`And the public server should be recognized as ${url.baseUrl}`);
|
||||
logger.log(`And the public server should be recognized as ${url.home}`);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ query adminServerConfig {
|
||||
baseUrl
|
||||
name
|
||||
features
|
||||
allowGuestDemoWorkspace
|
||||
type
|
||||
initialized
|
||||
credentialsRequirement {
|
||||
|
||||
@@ -32,7 +32,6 @@ export const adminServerConfigQuery = {
|
||||
baseUrl
|
||||
name
|
||||
features
|
||||
allowGuestDemoWorkspace
|
||||
type
|
||||
initialized
|
||||
credentialsRequirement {
|
||||
@@ -1823,7 +1822,6 @@ export const serverConfigQuery = {
|
||||
baseUrl
|
||||
name
|
||||
features
|
||||
allowGuestDemoWorkspace
|
||||
type
|
||||
initialized
|
||||
credentialsRequirement {
|
||||
|
||||
@@ -7,7 +7,6 @@ query serverConfig {
|
||||
baseUrl
|
||||
name
|
||||
features
|
||||
allowGuestDemoWorkspace
|
||||
type
|
||||
initialized
|
||||
credentialsRequirement {
|
||||
|
||||
@@ -708,7 +708,6 @@ export enum ErrorNames {
|
||||
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS',
|
||||
CAN_NOT_REVOKE_YOURSELF = 'CAN_NOT_REVOKE_YOURSELF',
|
||||
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
|
||||
COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND',
|
||||
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
|
||||
COPILOT_CONTEXT_FILE_NOT_SUPPORTED = 'COPILOT_CONTEXT_FILE_NOT_SUPPORTED',
|
||||
COPILOT_DOCS_NOT_FOUND = 'COPILOT_DOCS_NOT_FOUND',
|
||||
@@ -798,7 +797,6 @@ export enum ErrorNames {
|
||||
OWNER_CAN_NOT_LEAVE_WORKSPACE = 'OWNER_CAN_NOT_LEAVE_WORKSPACE',
|
||||
PASSWORD_REQUIRED = 'PASSWORD_REQUIRED',
|
||||
QUERY_TOO_LONG = 'QUERY_TOO_LONG',
|
||||
REPLY_NOT_FOUND = 'REPLY_NOT_FOUND',
|
||||
RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND',
|
||||
SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED',
|
||||
SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING',
|
||||
@@ -2149,8 +2147,6 @@ export enum SearchTable {
|
||||
|
||||
export interface ServerConfigType {
|
||||
__typename?: 'ServerConfigType';
|
||||
/** Whether allow guest users to create demo workspaces. */
|
||||
allowGuestDemoWorkspace: Scalars['Boolean']['output'];
|
||||
/** fetch latest available upgradable release of server */
|
||||
availableUpgrade: Maybe<ReleaseVersionType>;
|
||||
/** Features for user that can be configured */
|
||||
@@ -2745,7 +2741,6 @@ export type AdminServerConfigQuery = {
|
||||
baseUrl: string;
|
||||
name: string;
|
||||
features: Array<ServerFeature>;
|
||||
allowGuestDemoWorkspace: boolean;
|
||||
type: ServerDeploymentType;
|
||||
initialized: boolean;
|
||||
availableUserFeatures: Array<FeatureType>;
|
||||
@@ -4831,7 +4826,6 @@ export type ServerConfigQuery = {
|
||||
baseUrl: string;
|
||||
name: string;
|
||||
features: Array<ServerFeature>;
|
||||
allowGuestDemoWorkspace: boolean;
|
||||
type: ServerDeploymentType;
|
||||
initialized: boolean;
|
||||
credentialsRequirement: {
|
||||
|
||||
@@ -171,10 +171,6 @@
|
||||
"desc": "Where the server get deployed(FQDN).",
|
||||
"env": "AFFINE_SERVER_HOST"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "Array",
|
||||
"desc": "Multiple hosts the server will accept requests from."
|
||||
},
|
||||
"port": {
|
||||
"type": "Number",
|
||||
"desc": "Which port the server will listen on.",
|
||||
@@ -190,10 +186,6 @@
|
||||
"earlyAccessControl": {
|
||||
"type": "Boolean",
|
||||
"desc": "Only allow users with early access features to access the app"
|
||||
},
|
||||
"allowGuestDemoWorkspace": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether allow guest users to create demo workspaces."
|
||||
}
|
||||
},
|
||||
"docService": {
|
||||
@@ -211,10 +203,6 @@
|
||||
"versionControl.requiredVersion": {
|
||||
"type": "String",
|
||||
"desc": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect."
|
||||
},
|
||||
"allowGuestDemoWorkspace": {
|
||||
"type": "Boolean",
|
||||
"desc": "Allow guests to access demo workspace."
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
@@ -261,10 +249,6 @@
|
||||
"type": "Object",
|
||||
"desc": "The config for the anthropic provider in Google Vertex AI."
|
||||
},
|
||||
"providers.morph": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the morph provider."
|
||||
},
|
||||
"unsplash": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the unsplash key."
|
||||
|
||||
@@ -48,7 +48,7 @@ export const KNOWN_CONFIG_GROUPS = [
|
||||
{
|
||||
name: 'Server',
|
||||
module: 'server',
|
||||
fields: ['externalUrl', 'name', 'hosts'],
|
||||
fields: ['externalUrl', 'name'],
|
||||
} as ConfigGroup<'server'>,
|
||||
{
|
||||
name: 'Auth',
|
||||
|
||||
@@ -30,19 +30,7 @@ if (isDev) {
|
||||
app.commandLine.appendSwitch('host-rules', 'MAP 0.0.0.0 127.0.0.1');
|
||||
}
|
||||
// https://github.com/electron/electron/issues/43556
|
||||
// // `CalculateNativeWinOcclusion` - Disable native window occlusion tracker (https://groups.google.com/a/chromium.org/g/embedder-dev/c/ZF3uHHyWLKw/m/VDN2hDXMAAAJ)
|
||||
app.commandLine.appendSwitch(
|
||||
'disable-features',
|
||||
'PlzDedicatedWorker,CalculateNativeWinOcclusion'
|
||||
);
|
||||
|
||||
// Following features are enabled from the runtime:
|
||||
// `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental
|
||||
// `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065
|
||||
// `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065
|
||||
const featuresToEnable = `DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync`;
|
||||
app.commandLine.appendSwitch('enable-features', featuresToEnable);
|
||||
app.commandLine.appendSwitch('force-color-profile', 'srgb');
|
||||
app.commandLine.appendSwitch('disable-features', 'PlzDedicatedWorker');
|
||||
|
||||
// use the same data for internal & beta for testing
|
||||
if (overrideSession) {
|
||||
|
||||
@@ -160,48 +160,6 @@ export function registerProtocol() {
|
||||
delete responseHeaders['Access-Control-Allow-Origin'];
|
||||
delete responseHeaders['Access-Control-Allow-Headers'];
|
||||
}
|
||||
|
||||
// to allow url embedding, remove "x-frame-options",
|
||||
// if response header contains "content-security-policy", remove "frame-ancestors/frame-src"
|
||||
delete responseHeaders['x-frame-options'];
|
||||
delete responseHeaders['X-Frame-Options'];
|
||||
|
||||
// Handle Content Security Policy headers
|
||||
const cspHeaders = [
|
||||
'content-security-policy',
|
||||
'Content-Security-Policy',
|
||||
];
|
||||
for (const cspHeader of cspHeaders) {
|
||||
const cspValues = responseHeaders[cspHeader];
|
||||
if (cspValues) {
|
||||
// Remove frame-ancestors and frame-src directives from CSP
|
||||
const modifiedCspValues = cspValues
|
||||
.map(cspValue => {
|
||||
if (typeof cspValue === 'string') {
|
||||
return cspValue
|
||||
.split(';')
|
||||
.filter(directive => {
|
||||
const trimmed = directive.trim().toLowerCase();
|
||||
return (
|
||||
!trimmed.startsWith('frame-ancestors') &&
|
||||
!trimmed.startsWith('frame-src')
|
||||
);
|
||||
})
|
||||
.join(';');
|
||||
}
|
||||
return cspValue;
|
||||
})
|
||||
.filter(
|
||||
value => value && typeof value === 'string' && value.trim()
|
||||
);
|
||||
|
||||
if (modifiedCspValues.length > 0) {
|
||||
responseHeaders[cspHeader] = modifiedCspValues;
|
||||
} else {
|
||||
delete responseHeaders[cspHeader];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch(err => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -92,8 +92,6 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C45499AB2D140B5000E21978 /* NBStore */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = NBStore;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -342,9 +340,13 @@
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||
|
||||
@@ -12,14 +12,14 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
|
||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
||||
IntelligentContext.shared.webView = webView!
|
||||
button.beginProgress()
|
||||
|
||||
IntelligentContext.shared.preparePresent { result in
|
||||
|
||||
IntelligentContext.shared.preparePresent() { result in
|
||||
button.stopProgress()
|
||||
switch result {
|
||||
case .success:
|
||||
case .success(let success):
|
||||
let controller = IntelligentsController()
|
||||
self.present(controller, animated: true)
|
||||
case let .failure(failure):
|
||||
case .failure(let failure):
|
||||
let alert = UIAlertController(
|
||||
title: "Error",
|
||||
message: failure.localizedDescription,
|
||||
|
||||
@@ -13,15 +13,15 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
intelligentsButton.delegate = self
|
||||
dismissIntelligentsButton()
|
||||
}
|
||||
|
||||
|
||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||
let configuration = super.webViewConfiguration(for: instanceConfiguration)
|
||||
return configuration
|
||||
}
|
||||
|
||||
|
||||
override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView {
|
||||
super.webView(with: frame, configuration: configuration)
|
||||
}
|
||||
return super.webView(with: frame, configuration: configuration)
|
||||
}
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
let plugins: [CAPPlugin] = [
|
||||
@@ -43,3 +43,6 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ final class AppConfigManager {
|
||||
struct AppConfig: Decodable {
|
||||
let affineVersion: String
|
||||
}
|
||||
|
||||
static var affineVersion: String?
|
||||
|
||||
|
||||
static var affineVersion: String? = nil
|
||||
|
||||
static func getAffineVersion() -> String {
|
||||
if affineVersion == nil {
|
||||
let file = Bundle(for: AppConfigManager.self).url(forResource: "capacitor.config", withExtension: "json")!
|
||||
@@ -14,7 +14,7 @@ final class AppConfigManager {
|
||||
let config = try! JSONDecoder().decode(AppConfig.self, from: data)
|
||||
affineVersion = config.affineVersion
|
||||
}
|
||||
|
||||
|
||||
return affineVersion!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ enum ApplicationBridgedWindowScript: String {
|
||||
|
||||
var requiresAsyncContext: Bool {
|
||||
switch self {
|
||||
case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: true
|
||||
default: false
|
||||
case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WKWebView {
|
||||
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> Void) {
|
||||
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> ()) {
|
||||
if script.requiresAsyncContext {
|
||||
callAsyncJavaScript(
|
||||
script.rawValue,
|
||||
@@ -38,7 +38,7 @@ extension WKWebView {
|
||||
in: .page
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(input):
|
||||
case .success(let input):
|
||||
callback(input)
|
||||
case .failure:
|
||||
callback(nil)
|
||||
@@ -49,3 +49,5 @@ extension WKWebView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,44 +10,44 @@ enum RequestParamError: Error {
|
||||
case request(key: String)
|
||||
}
|
||||
|
||||
public extension JSValueContainer {
|
||||
func getStringEnsure(_ key: String) throws -> String {
|
||||
guard let str = getString(key) else {
|
||||
extension JSValueContainer {
|
||||
public func getStringEnsure(_ key: String) throws -> String {
|
||||
guard let str = self.getString(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func getIntEnsure(_ key: String) throws -> Int {
|
||||
guard let int = getInt(key) else {
|
||||
|
||||
public func getIntEnsure(_ key: String) throws -> Int {
|
||||
guard let int = self.getInt(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return int
|
||||
}
|
||||
|
||||
func getDoubleEnsure(_ key: String) throws -> Double {
|
||||
guard let doub = getDouble(key) else {
|
||||
|
||||
public func getDoubleEnsure(_ key: String) throws -> Double {
|
||||
guard let doub = self.getDouble(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return doub
|
||||
}
|
||||
|
||||
func getBoolEnsure(_ key: String) throws -> Bool {
|
||||
guard let bool = getBool(key) else {
|
||||
|
||||
public func getBoolEnsure(_ key: String) throws -> Bool {
|
||||
guard let bool = self.getBool(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return bool
|
||||
}
|
||||
|
||||
func getArrayEnsure(_ key: String) throws -> JSArray {
|
||||
guard let arr = getArray(key) else {
|
||||
|
||||
public func getArrayEnsure(_ key: String) throws -> JSArray {
|
||||
guard let arr = self.getArray(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
func getArrayEnsure<T>(_ key: String, _ ofType: T.Type) throws -> [T] {
|
||||
guard let arr = getArray(key, ofType) else {
|
||||
|
||||
public func getArrayEnsure<T>(_ key: String, _ ofType: T.Type) throws -> [T] {
|
||||
guard let arr = self.getArray(key, ofType) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return arr
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
import Foundation
|
||||
|
||||
final class Mutex<Wrapped>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let lock = NSLock.init()
|
||||
private var wrapped: Wrapped
|
||||
|
||||
|
||||
init(_ wrapped: Wrapped) {
|
||||
self.wrapped = wrapped
|
||||
}
|
||||
|
||||
|
||||
func withLock<R>(_ body: @Sendable (inout Wrapped) throws -> R) rethrows -> R {
|
||||
lock.lock()
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return try body(&wrapped)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
|
||||
@objc public func signInMagicLink(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -18,7 +18,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let email = try call.getStringEnsure("email")
|
||||
let token = try call.getStringEnsure("token")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
@@ -28,19 +28,19 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
call.resolve(["token": token])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc public func signInOauth(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -48,9 +48,9 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let code = try call.getStringEnsure("code")
|
||||
let state = try call.getStringEnsure("state")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/oauth/callback", headers: [:], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
@@ -58,19 +58,19 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
call.resolve(["token": token])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc public func signInPassword(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -79,12 +79,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let password = try call.getStringEnsure("password")
|
||||
let verifyToken = call.getString("verifyToken")
|
||||
let challenge = call.getString("challenge")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
"x-captcha-challenge": challenge
|
||||
], body: ["email": email, "password": password])
|
||||
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
@@ -92,24 +92,24 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
call.resolve(["token": token])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc public func signOut(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "GET", action: "/api/auth/sign-out", headers: [:], body: nil)
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
@@ -119,19 +119,20 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func tokenFromCookie(_ endpoint: String) throws -> String? {
|
||||
guard let endpointUrl = URL(string: endpoint) else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
|
||||
if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: {
|
||||
$0.name == "affine_session"
|
||||
}) {
|
||||
@@ -140,14 +141,14 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func fetch(_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
||||
|
||||
private func fetch(_ endpoint: String, method: String, action: String, headers: Dictionary<String, String?>, body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
var request = URLRequest(url: targetUrl)
|
||||
request.httpMethod = method
|
||||
|
||||
var request = URLRequest(url: targetUrl);
|
||||
request.httpMethod = method;
|
||||
request.httpShouldHandleCookies = true
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
@@ -158,8 +159,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
|
||||
request.timeoutInterval = 10 // time out 10s
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request);
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user