Compare commits

..

1 Commits

Author SHA1 Message Date
liuyi ba78a6bd45 refactor(server): use duration helper 2025-06-27 11:12:15 +08:00
95 changed files with 356 additions and 1715 deletions
-5
View File
@@ -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`",
+5 -10
View File
@@ -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,
+6 -8
View File
@@ -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 }}
+1 -7
View File
@@ -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;
@@ -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
+1 -7
View File
@@ -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
View File
@@ -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
@@ -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');
});
+14 -48
View File
@@ -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,
@@ -12,7 +12,6 @@ declare global {
externalUrl?: string;
https: boolean;
host: string;
hosts: ConfigItem<string[]>;
port: number;
path: string;
name?: string;
@@ -53,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,
@@ -82,7 +82,7 @@ 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,
};
@@ -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`;
}
@@ -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, {
-3
View File
@@ -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,
@@ -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,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,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 {
@@ -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;
}
@@ -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,
];
}
+1 -1
View File
@@ -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}`);
}
-4
View File
@@ -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.",
@@ -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
}
@@ -1,8 +1,8 @@
import Capacitor
import Foundation
// @objc(IntelligentsPlugin)
// public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
//@objc(IntelligentsPlugin)
//public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
// public let identifier = "IntelligentsPlugin"
// public let jsName = "Intelligents"
// public let pluginMethods: [CAPPluginMethod] = [
@@ -33,4 +33,4 @@ import Foundation
// call.resolve()
// }
// }
// }
//}
@@ -4,7 +4,7 @@ import Foundation
@objc(NbStorePlugin)
public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
private let docStoragePool: DocStoragePool = newDocStoragePool()
public let identifier = "NbStorePlugin"
public let jsName = "NbStoreDocStorage"
public let pluginMethods: [CAPPluginMethod] = [
@@ -37,7 +37,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "getBlobUploadedAt", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setBlobUploadedAt", returnType: CAPPluginReturnPromise),
]
@objc func connect(_ call: CAPPluginCall) {
Task {
do {
@@ -52,10 +52,10 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
let peerDir = documentDir.appending(path: "workspaces")
.appending(path: spaceType)
.appending(path:
peer
.replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_")
.replacing(/_+/, with: "_")
.replacing(/_+$/, with: ""))
peer
.replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_")
.replacing(/_+/, with: "_")
.replacing(/_+$/, with: ""))
try FileManager.default.createDirectory(atPath: peerDir.path(), withIntermediateDirectories: true)
let db = peerDir.appending(path: spaceId + ".db")
try await docStoragePool.connect(universalId: id, path: db.path())
@@ -65,7 +65,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func disconnect(_ call: CAPPluginCall) {
Task {
do {
@@ -77,7 +77,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func setSpaceId(_ call: CAPPluginCall) {
Task {
do {
@@ -90,7 +90,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func pushUpdate(_ call: CAPPluginCall) {
Task {
do {
@@ -104,13 +104,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getDocSnapshot(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
if let record = try await docStoragePool.getDocSnapshot(universalId: id, docId: docId) {
call.resolve([
"docId": record.docId,
@@ -125,7 +125,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func setDocSnapshot(_ call: CAPPluginCall) {
Task {
do {
@@ -143,7 +143,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getDocUpdates(_ call: CAPPluginCall) {
Task {
do {
@@ -161,14 +161,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func markUpdatesMerged(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
let times = try call.getArrayEnsure("timestamps", Int64.self)
let count = try await docStoragePool.markUpdatesMerged(universalId: id, docId: docId, updates: times)
call.resolve(["count": count])
} catch {
@@ -176,13 +176,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func deleteDoc(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
try await docStoragePool.deleteDoc(universalId: id, docId: docId)
call.resolve()
} catch {
@@ -190,13 +190,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getDocClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let after = call.getInt("after")
let docClocks = try await docStoragePool.getDocClocks(
universalId: id,
after: after != nil ? Int64(after!) : nil
@@ -211,7 +211,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getDocClock(_ call: CAPPluginCall) {
Task {
do {
@@ -230,7 +230,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getBlob(_ call: CAPPluginCall) {
Task {
do {
@@ -242,7 +242,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
"data": blob.data,
"mime": blob.mime,
"size": blob.size,
"createdAt": blob.createdAt,
"createdAt": blob.createdAt
])
} else {
call.resolve()
@@ -252,7 +252,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func setBlob(_ call: CAPPluginCall) {
Task {
do {
@@ -267,7 +267,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func deleteBlob(_ call: CAPPluginCall) {
Task {
do {
@@ -281,7 +281,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func releaseBlobs(_ call: CAPPluginCall) {
Task {
do {
@@ -293,7 +293,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func listBlobs(_ call: CAPPluginCall) {
Task {
do {
@@ -311,13 +311,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getPeerRemoteClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let clocks = try await docStoragePool.getPeerRemoteClocks(universalId: id, peer: peer)
let mapped = clocks.map { [
"docId": $0.docId,
@@ -329,14 +329,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getPeerRemoteClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
if let clock = try await docStoragePool.getPeerRemoteClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
@@ -345,13 +345,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer remote clock, \(error)", nil, error)
}
}
}
@objc func setPeerRemoteClock(_ call: CAPPluginCall) {
Task {
do {
@@ -371,13 +371,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getPeerPulledRemoteClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let clocks = try await docStoragePool.getPeerPulledRemoteClocks(universalId: id, peer: peer)
let mapped = clocks.map { [
"docId": $0.docId,
@@ -389,14 +389,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getPeerPulledRemoteClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
if let clock = try await docStoragePool.getPeerPulledRemoteClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
@@ -405,13 +405,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer pulled remote clock, \(error)", nil, error)
}
}
}
@objc func setPeerPulledRemoteClock(_ call: CAPPluginCall) {
Task {
do {
@@ -419,7 +419,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
let timestamp = try call.getIntEnsure("timestamp")
try await docStoragePool.setPeerPulledRemoteClock(
universalId: id,
peer: peer,
@@ -432,7 +432,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getPeerPushedClock(_ call: CAPPluginCall) {
Task {
do {
@@ -452,7 +452,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getPeerPushedClocks(_ call: CAPPluginCall) {
Task {
do {
@@ -464,13 +464,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
"timestamp": $0.timestamp,
] }
call.resolve(["clocks": mapped])
} catch {
call.reject("Failed to get peer pushed clocks, \(error)", nil, error)
}
}
}
@objc func setPeerPushedClock(_ call: CAPPluginCall) {
Task {
do {
@@ -478,7 +478,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
let timestamp = try call.getIntEnsure("timestamp")
try await docStoragePool.setPeerPushedClock(
universalId: id,
peer: peer,
@@ -491,29 +491,29 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
}
@objc func getBlobUploadedAt(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let blobId = try call.getStringEnsure("blobId")
let uploadedAt = try await docStoragePool.getBlobUploadedAt(
universalId: id,
peer: peer,
blobId: blobId
)
call.resolve([
"uploadedAt": uploadedAt as Any,
"uploadedAt": uploadedAt as Any
])
} catch {
call.reject("Failed to get blob uploaded, \(error)", nil, error)
}
}
}
@objc func setBlobUploadedAt(_ call: CAPPluginCall) {
Task {
do {
@@ -521,7 +521,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
let peer = try call.getStringEnsure("peer")
let blobId = try call.getStringEnsure("blobId")
let uploadedAt = call.getInt("uploadedAt")
try await docStoragePool.setBlobUploadedAt(
universalId: id,
peer: peer,
@@ -11,24 +11,26 @@ class SafeWKURLSchemeTask: WKURLSchemeTask, NSObject {
var origin: any WKURLSchemeTask
init(origin: any WKURLSchemeTask) {
self.origin = origin
request = origin.request
self.request = origin.request
}
var request: URLRequest
func didReceive(_: URLResponse) {
func didReceive(_ response: URLResponse) {
<#code#>
}
func didReceive(_: Data) {
origin.didReceive(<#T##response: URLResponse##URLResponse#>)
func didReceive(_ data: Data) {
self.origin.didReceive(<#T##response: URLResponse##URLResponse#>)
}
func didFinish() {
origin.didFinish()
self.origin.didFinish()
}
func didFailWithError(_ error: any Error) {
origin.didFailWithError(error)
self.origin.didFailWithError(error)
}
}
@@ -39,3 +39,4 @@ public enum CustomJSON: CustomScalarType, Hashable {
hasher.combine(_jsonValue)
}
}
@@ -1,125 +0,0 @@
//
// ChatManager+ContextModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Foundation
// MARK: - ChatManager Context Models Extension
extension ChatManager {
// MARK: - Context Models
struct ContextReference: Codable, Identifiable, Equatable, Hashable {
var id: String
var fileId: String?
var docId: String?
var chunk: Int
var content: String
var distance: Double
var highlightedContent: String?
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
id = UUID().uuidString
self.fileId = fileId
self.docId = docId
self.chunk = chunk
self.content = content
self.distance = distance
self.highlightedContent = highlightedContent
}
}
struct CopilotContext: Codable, Identifiable, Equatable, Hashable {
var id: String
var sessionId: String
var workspaceId: String
var files: [ContextFile]
var docs: [ContextDoc]
var categories: [ContextCategory]
init(id: String, sessionId: String, workspaceId: String, files: [ContextFile] = [], docs: [ContextDoc] = [], categories: [ContextCategory] = []) {
self.id = id
self.sessionId = sessionId
self.workspaceId = workspaceId
self.files = files
self.docs = docs
self.categories = categories
}
}
struct ContextFile: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var blobId: String
var fileName: String?
var fileSize: Int?
var mimeType: String?
var embeddingStatus: ContextEmbedStatus?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
struct ContextDoc: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var docId: String
var title: String?
var embeddingStatus: ContextEmbedStatus?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
struct ContextCategory: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var type: ContextCategoryType
var docs: [String]
var name: String?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
enum ContextEmbedStatus: String, Codable, CaseIterable {
case pending = "Pending"
case failed = "Failed"
case completed = "Completed"
}
enum ContextCategoryType: String, Codable, CaseIterable {
case tag = "TAG"
case collection = "COLLECTION"
}
struct MatchContextResult: Codable, Identifiable, Equatable, Hashable {
var id: String
var fileId: String?
var docId: String?
var chunk: Int
var content: String
var distance: Double
var highlightedContent: String?
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
id = UUID().uuidString
self.fileId = fileId
self.docId = docId
self.chunk = chunk
self.content = content
self.distance = distance
self.highlightedContent = highlightedContent
}
}
}
@@ -1,44 +0,0 @@
//
// ChatManager+InputModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
// MARK: - ChatManager Input Models Extension
extension ChatManager {
// MARK: - Input Models
struct AddContextFileInput: Codable, Equatable, Hashable {
var contextId: String
var blobId: String
}
struct RemoveContextFileInput: Codable, Equatable, Hashable {
var contextId: String
var fileId: String
}
struct AddContextDocInput: Codable, Equatable, Hashable {
var contextId: String
var docId: String
}
struct RemoveContextDocInput: Codable, Equatable, Hashable {
var contextId: String
var docId: String
}
struct AddContextCategoryInput: Codable, Equatable, Hashable {
var contextId: String
var docs: [String]
}
struct RemoveContextCategoryInput: Codable, Equatable, Hashable {
var contextId: String
var categoryId: String
}
}
@@ -1,74 +0,0 @@
//
// ChatManager+WorkflowModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Foundation
// MARK: - ChatManager Workflow Models Extension
extension ChatManager {
// MARK: - Workflow Models
struct WorkflowEventData: Codable, Identifiable, Equatable, Hashable {
var id: String
var status: String
var type: String
var progress: Double?
var message: String?
init(status: String, type: String, progress: Double? = nil, message: String? = nil) {
id = UUID().uuidString
self.status = status
self.type = type
self.progress = progress
self.message = message
}
}
struct WorkspaceEmbeddingStatus: Codable, Identifiable, Equatable, Hashable {
var id: String
var workspaceId: String
var total: Int
var embedded: Int
var progress: Double {
total > 0 ? Double(embedded) / Double(total) : 0.0
}
init(workspaceId: String, total: Int, embedded: Int) {
id = workspaceId
self.workspaceId = workspaceId
self.total = total
self.embedded = embedded
}
}
struct ChatEvent: Codable, Identifiable, Equatable, Hashable {
var id: String
var type: ChatEventType
var data: String
var timestamp: DateTime?
var timestampDate: Date? {
timestamp?.decoded
}
init(type: ChatEventType, data: String, timestamp: DateTime? = nil) {
id = UUID().uuidString
self.type = type
self.data = data
self.timestamp = timestamp
}
}
enum ChatEventType: String, Codable, CaseIterable {
case message
case attachment
case event
case ping
}
}
@@ -1,227 +0,0 @@
//
// ChatManager.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Apollo
import Combine
import Foundation
// MARK: - ChatManager
public class ChatManager: ObservableObject {
public static let shared = ChatManager()
// MARK: - Properties
@Published public private(set) var sessions: [SessionViewModel] = []
@Published public private(set) var currentSession: SessionViewModel?
@Published public private(set) var messages: [String: [ChatMessage]] = [:]
@Published public private(set) var isLoading = false
@Published public private(set) var error: Error?
private var cancellables = Set<AnyCancellable>()
private let apolloClient: ApolloClient
// MARK: - Initialization
private init(apolloClient: ApolloClient = QLService.shared.client) {
self.apolloClient = apolloClient
}
// MARK: - Public Methods
public func createSession(
workspaceId: String,
promptName: String = "",
docId: String? = nil,
pinned: Bool = false
) async throws -> SessionViewModel {
isLoading = true
error = nil
do {
let input = CreateChatSessionInput(
docId: docId.map { .some($0) } ?? .null,
pinned: .some(pinned),
promptName: promptName,
workspaceId: workspaceId
)
let mutation = CreateCopilotSessionMutation(options: input)
return try await withCheckedThrowingContinuation { continuation in
apolloClient.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let sessionId = graphQLResult.data?.createCopilotSession else {
continuation.resume(throwing: ChatError.invalidResponse)
return
}
let session = SessionViewModel(
id: sessionId,
workspaceId: workspaceId,
docId: docId,
promptName: promptName,
model: nil,
pinned: pinned,
tokens: 0,
createdAt: DateTime(date: Date()),
updatedAt: DateTime(date: Date()),
parentSessionId: nil
)
Task { @MainActor in
self.sessions.append(session)
self.currentSession = session
self.messages[sessionId] = []
self.isLoading = false
}
continuation.resume(returning: session)
case let .failure(error):
Task { @MainActor in
self.error = error
self.isLoading = false
}
continuation.resume(throwing: error)
}
}
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
throw error
}
}
public func sendMessage(
content: String,
attachments: [String] = [],
sessionId: String? = nil
) async throws {
guard let targetSessionId = sessionId ?? currentSession?.id else {
throw ChatError.noActiveSession
}
isLoading = true
error = nil
// Add user message immediately
let userMessage = ChatMessage(
id: UUID().uuidString,
role: .user,
content: content,
attachments: attachments.isEmpty ? nil : attachments,
params: nil,
createdAt: DateTime(date: Date())
)
await MainActor.run {
var sessionMessages = self.messages[targetSessionId] ?? []
sessionMessages.append(userMessage)
self.messages[targetSessionId] = sessionMessages
}
do {
let input = CreateChatMessageInput(
attachments: attachments.isEmpty ? .null : .some(attachments),
blobs: .null,
content: .some(content),
params: .null,
sessionId: targetSessionId
)
let mutation = CreateCopilotMessageMutation(options: input)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
apolloClient.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let messageId = graphQLResult.data?.createCopilotMessage else {
continuation.resume(throwing: ChatError.invalidResponse)
return
}
// Add assistant message placeholder
let assistantMessage = ChatMessage(
id: messageId,
role: .assistant,
content: "Thinking...",
attachments: nil,
params: nil,
createdAt: DateTime(date: Date())
)
Task { @MainActor in
var sessionMessages = self.messages[targetSessionId] ?? []
sessionMessages.append(assistantMessage)
self.messages[targetSessionId] = sessionMessages
self.isLoading = false
}
continuation.resume()
// TODO: Implement streaming response handling
case let .failure(error):
Task { @MainActor in
self.error = error
self.isLoading = false
}
continuation.resume(throwing: error)
}
}
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
throw error
}
}
public func switchToSession(_ session: SessionViewModel) {
currentSession = session
}
public func deleteSession(sessionId: String) {
sessions.removeAll { $0.id == sessionId }
messages.removeValue(forKey: sessionId)
if currentSession?.id == sessionId {
currentSession = sessions.first
}
}
public func clearError() {
error = nil
}
}
// MARK: - ChatError
public enum ChatError: LocalizedError {
case noActiveSession
case invalidResponse
case networkError(Error)
public var errorDescription: String? {
switch self {
case .noActiveSession:
"No active chat session"
case .invalidResponse:
"Invalid response from server"
case let .networkError(error):
"Network error: \(error.localizedDescription)"
}
}
}
@@ -1,95 +0,0 @@
//
// ChatMessage.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Foundation
public struct ChatMessage: Codable, Identifiable, Equatable, Hashable {
public var id: String?
public var role: MessageRole
public var content: String
public var attachments: [String]?
public var params: [String: String]?
public var createdAt: DateTime?
public var createdDate: Date? {
createdAt?.decoded
}
public var messageId: String {
id ?? UUID().uuidString
}
public init(
id: String? = nil,
role: MessageRole,
content: String,
attachments: [String]? = nil,
params: [String: String]? = nil,
createdAt: DateTime? = nil
) {
self.id = id
self.role = role
self.content = content
self.attachments = attachments
self.params = params
self.createdAt = createdAt
}
}
public extension ChatMessage {
enum MessageRole: String, Codable, CaseIterable {
case user
case assistant
case system
}
}
public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable {
public var id: String
public var workspaceId: String
public var docId: String?
public var promptName: String
public var model: String?
public var pinned: Bool
public var tokens: Int
public var createdAt: DateTime?
public var updatedAt: DateTime?
public var parentSessionId: String?
public var createdDate: Date? {
createdAt?.decoded
}
public var updatedDate: Date? {
updatedAt?.decoded
}
public init(
id: String,
workspaceId: String,
docId: String? = nil,
promptName: String,
model: String? = nil,
pinned: Bool,
tokens: Int,
createdAt: DateTime? = nil,
updatedAt: DateTime? = nil,
parentSessionId: String? = nil
) {
self.id = id
self.workspaceId = workspaceId
self.docId = docId
self.promptName = promptName
self.model = model
self.pinned = pinned
self.tokens = tokens
self.createdAt = createdAt
self.updatedAt = updatedAt
self.parentSessionId = parentSessionId
}
}
@@ -11,6 +11,7 @@ import ApolloAPI
import Foundation
/// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
extension DateTime {
extension DateTime {
private static let formatter: DateFormatter = {
let fmt = DateFormatter()
@@ -19,11 +20,8 @@ extension DateTime {
return fmt
}()
init(date: Date) {
self.init(Self.formatter.string(from: date))
}
var decoded: Date? {
Self.formatter.date(from: self)
return Self.formatter.date(from: self)
}
}
}
@@ -5,7 +5,7 @@ import Foundation
public final class QLService {
public static let shared = QLService()
private var endpointURL: URL
public var client: ApolloClient
public private(set) var client: ApolloClient
private init() {
let store = ApolloStore()
@@ -75,48 +75,7 @@ extension MainViewController: InputBoxDelegate {
}
func inputBoxDidSend(_ inputBox: InputBox) {
let inputData = inputBox.inputBoxData
Task { @MainActor in
do {
let chatManager = ChatManager.shared
if let currentSession = chatManager.currentSession {
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
sessionId: currentSession.id
)
} else {
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
showAlert(title: "Error", message: "No workspace available")
return
}
let session = try await chatManager.createSession(workspaceId: workspaceId)
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
sessionId: session.id
)
}
inputBox.text = ""
inputBox.viewModel.clearAllAttachments()
} catch {
showAlert(title: "Error", message: error.localizedDescription)
}
}
}
private func showAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
print(#function, inputBox, inputBox.viewModel)
}
func inputBoxTextDidChange(_ text: String) {
@@ -10,27 +10,6 @@ class MainViewController: UIViewController {
$0.delegate = self
}
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
$0.delegate = self
$0.dataSource = self
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
lazy var inputBox = InputBox().then {
$0.delegate = self
}
@@ -49,10 +28,8 @@ class MainViewController: UIViewController {
// MARK: - Properties
private var messages: [ChatMessage] = []
private var cancellables = Set<AnyCancellable>()
private let intelligentContext = IntelligentContext.shared
private let chatManager = ChatManager.shared
var terminateEditGesture: UITapGestureRecognizer!
// MARK: - Lifecycle
@@ -61,46 +38,21 @@ class MainViewController: UIViewController {
super.viewDidLoad()
view.backgroundColor = .affineLayerBackgroundPrimary
setupUI()
setupBindings()
let inputBox = InputBox().then {
$0.delegate = self
}
self.inputBox = inputBox
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
}
// MARK: - Setup
private func setupUI() {
view.addSubview(headerView)
view.addSubview(tableView)
view.addSubview(emptyStateView)
view.addSubview(inputBox)
view.addSubview(documentPickerHideDetector)
view.addSubview(documentPickerView)
emptyStateView.addSubview(emptyStateLabel)
headerView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
tableView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(inputBox.snp.top)
}
emptyStateView.snp.makeConstraints { make in
make.center.equalTo(tableView)
make.width.lessThanOrEqualTo(tableView).inset(32)
}
emptyStateLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
inputBox.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
@@ -115,24 +67,10 @@ class MainViewController: UIViewController {
make.leading.trailing.equalToSuperview()
make.height.equalTo(500)
}
}
private func setupBindings() {
chatManager.$currentSession
.receive(on: DispatchQueue.main)
.sink { [weak self] session in
self?.updateMessages(for: session?.id)
}
.store(in: &cancellables)
chatManager.$messages
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
if let sessionId = self?.chatManager.currentSession?.id {
self?.updateMessages(for: sessionId)
}
}
.store(in: &cancellables)
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
}
override func viewWillAppear(_ animated: Bool) {
@@ -152,66 +90,4 @@ class MainViewController: UIViewController {
@objc func terminateEditing() {
view.endEditing(true)
}
// MARK: - Chat Methods
private func updateMessages(for sessionId: String?) {
guard let sessionId else {
messages = []
updateEmptyState()
tableView.reloadData()
return
}
messages = chatManager.messages[sessionId] ?? []
updateEmptyState()
tableView.reloadData()
if !messages.isEmpty {
let indexPath = IndexPath(row: messages.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
private func updateEmptyState() {
emptyStateView.isHidden = !messages.isEmpty
tableView.isHidden = messages.isEmpty
}
// MARK: - Internal Methods for Preview/Testing
#if DEBUG
func setMessagesForPreview(_ previewMessages: [ChatMessage]) {
messages = previewMessages
updateEmptyState()
tableView.reloadData()
}
#endif
}
// MARK: - UITableViewDataSource
extension MainViewController: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
let message = messages[indexPath.row]
cell.configure(with: message)
return cell
}
}
// MARK: - UITableViewDelegate
extension MainViewController: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
60
}
}
@@ -1,155 +0,0 @@
//
// ChatCell.swift
// Intelligents
//
// Created by on 6/26/25.
//
import SnapKit
import Then
import UIKit
class ChatCell: UITableViewCell {
// MARK: - UI Components
private lazy var avatarImageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.layer.cornerRadius = 16
$0.layer.cornerCurve = .continuous
$0.clipsToBounds = true
$0.backgroundColor = .systemGray5
}
private lazy var messageContainerView = UIView().then {
$0.layer.cornerRadius = 12
$0.layer.cornerCurve = .continuous
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .systemGray
$0.textAlignment = .right
}
private lazy var stackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var messageStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
}
// MARK: - Properties
private var message: ChatMessage?
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(stackView)
messageStackView.addArrangedSubview(messageContainerView)
messageStackView.addArrangedSubview(timestampLabel)
messageContainerView.addSubview(messageLabel)
stackView.addArrangedSubview(avatarImageView)
stackView.addArrangedSubview(messageStackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
avatarImageView.snp.makeConstraints { make in
make.size.equalTo(32)
}
messageLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
messageStackView.snp.makeConstraints { make in
make.width.lessThanOrEqualTo(250)
}
}
// MARK: - Configuration
func configure(with message: ChatMessage) {
self.message = message
messageLabel.text = message.content
if let createdDate = message.createdDate {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
timestampLabel.text = formatter.string(from: createdDate)
} else {
timestampLabel.text = ""
}
switch message.role {
case .user:
configureUserMessage()
case .assistant:
configureAssistantMessage()
case .system:
configureSystemMessage()
}
}
private func configureUserMessage() {
// User message - align to right
stackView.semanticContentAttribute = .forceRightToLeft
messageContainerView.backgroundColor = .systemBlue
messageLabel.textColor = .white
avatarImageView.image = UIImage(systemName: "person.circle.fill")
avatarImageView.tintColor = .systemBlue
timestampLabel.textAlignment = .left
}
private func configureAssistantMessage() {
// Assistant message - align to left
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemGray6
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "brain.head.profile")
avatarImageView.tintColor = .systemPurple
timestampLabel.textAlignment = .right
}
private func configureSystemMessage() {
// System message - center aligned
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "gear")
avatarImageView.tintColor = .systemOrange
timestampLabel.textAlignment = .center
}
}
@@ -1,23 +0,0 @@
//
// AttachmentCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct AttachmentCellViewModel: ChatCellViewModel {
var cellType: CellType = .attachment
var id: String
var attachments: [AttachmentViewModel]
var parentMessageId: String
}
struct AttachmentViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var url: String
var mimeType: String?
var fileName: String?
var size: Int64?
}
@@ -1,20 +0,0 @@
//
// CellType.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
enum CellType: String, Codable, CaseIterable {
case userMessage
case assistantMessage
case systemMessage
case attachment
case contextReference
case workflowStatus
case transcription
case loading
case error
}
@@ -1,41 +0,0 @@
//
// ChatCellViewModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
var cellType: CellType { get }
var id: String { get }
}
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .userMessage
var id: String
var content: String
var attachments: [AttachmentViewModel]
var timestamp: Date?
var isRetrying: Bool
}
struct AssistantMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .assistantMessage
var id: String
var content: String
var attachments: [AttachmentViewModel]
var timestamp: Date?
var isStreaming: Bool
var model: String?
var tokens: Int?
var canRetry: Bool
}
struct SystemMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .systemMessage
var id: String
var content: String
var timestamp: Date?
}
@@ -1,15 +0,0 @@
//
// ContextReferenceCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct ContextReferenceCellViewModel: ChatCellViewModel {
var cellType: CellType = .contextReference
var id: String
var references: [ChatManager.ContextReference]
var parentMessageId: String
}
@@ -1,16 +0,0 @@
//
// ErrorCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct ErrorCellViewModel: ChatCellViewModel {
var cellType: CellType = .error
var id: String
var errorMessage: String
var canRetry: Bool
var retryAction: String?
}
@@ -1,15 +0,0 @@
//
// LoadingCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct LoadingCellViewModel: ChatCellViewModel {
var cellType: CellType = .loading
var id: String
var message: String?
var progress: Double?
}
@@ -1,15 +0,0 @@
//
// WorkflowStatusCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct WorkflowStatusCellViewModel: ChatCellViewModel {
var cellType: CellType = .workflowStatus
var id: String
var workflow: ChatManager.WorkflowEventData
var parentMessageId: String
}
@@ -66,12 +66,6 @@ public class InputBoxViewModel: ObservableObject {
.assign(to: \.canSend, on: self)
.store(in: &cancellables)
}
public func clearAllAttachments() {
imageAttachments.removeAll()
fileAttachments.removeAll()
documentAttachments.removeAll()
}
}
// MARK: - Text Management
@@ -1,7 +1,7 @@
import { style } from '@vanilla-extract/css';
export const fallback = style({
padding: '4px 8px',
padding: '4px 16px',
height: '100%',
overflow: 'clip',
});
@@ -13,7 +13,7 @@ export const fallbackHeader = style({
flexDirection: 'row',
gap: '8px',
overflow: 'hidden',
height: '42px',
height: '52px',
});
export const spacer = style({
@@ -63,11 +63,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
? [
{
id: 'affine-cloud',
baseUrl: BUILD_CONFIG.isNative
? BUILD_CONFIG.isIOS
? 'https://apple.getaffineapp.com'
: 'https://app.affine.pro'
: location.origin,
baseUrl: 'https://app.affine.pro',
config: {
serverName: 'Affine Cloud',
features: [
@@ -95,11 +91,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
? [
{
id: 'affine-cloud',
baseUrl: BUILD_CONFIG.isNative
? BUILD_CONFIG.isIOS
? 'https://apple.getaffineapp.com'
: 'https://insider.affine.pro'
: location.origin,
baseUrl: 'https://insider.affine.pro',
config: {
serverName: 'Affine Cloud',
features: [
@@ -155,9 +147,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
? [
{
id: 'affine-cloud',
baseUrl: BUILD_CONFIG.isNative
? 'https://affine.fail'
: location.origin,
baseUrl: 'https://affine.fail',
config: {
serverName: 'Affine Cloud',
features: [
@@ -8,7 +8,6 @@ function maybeAffineOrigin(origin: string, baseUrl: string) {
return (
origin.startsWith('file://') ||
origin.endsWith('affine.pro') || // stable/beta
origin.endsWith('apple.getaffineapp.com') || // stable/beta
origin.endsWith('affine.fail') || // canary
origin === baseUrl // localhost or self-hosted
);
@@ -80,7 +80,7 @@ export class DesktopStateSynchronizer extends Service {
this.electronApi.events.ui.onToggleRightSidebar(tabId => {
if (tabId === appInfo?.viewId) {
workbench.setSidebarOpen(!workbench.sidebarOpen$.value);
workbench.sidebarOpen$.next(!workbench.sidebarOpen$.value);
}
});
@@ -19,18 +19,13 @@ export const header = style({
alignItems: 'center',
flexShrink: 0,
background: cssVar('backgroundPrimaryColor'),
padding: '0 16px',
padding: '0 16px 0px 8px',
contain: 'strict',
'@media': {
print: {
display: 'none',
},
},
selectors: {
'&[data-show-switch=true]': {
paddingLeft: 8,
},
},
});
export const viewBodyContainer = style({
@@ -52,15 +52,10 @@ export const RouteContainer = () => {
workbench.toggleSidebar();
}, [workbench]);
const showSwitch = !BUILD_CONFIG.isElectron && viewPosition.isFirst;
return (
<div className={styles.root}>
<div
className={styles.header}
data-show-switch={showSwitch && !leftSidebarOpen}
>
{showSwitch && (
<div className={styles.header}>
{!BUILD_CONFIG.isElectron && viewPosition.isFirst && (
<SidebarSwitch
show={!leftSidebarOpen}
className={styles.leftSidebarButton}