Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions prisma/hyperdrive.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,17 @@ model sr_user_bookmark_overview {

@@index([bookmark_id, user_id])
}

// Per-user switches for Labs features. One row per (user, feature); no row means off.
// created_at = first time the user touched the switch, updated_at = last change.
model sr_user_lab_feature {
id Int @id @default(autoincrement())
user_id Int
feature String
enabled Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt

@@unique([user_id, feature])
@@index([feature, enabled])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- sr_user_lab_feature: per-user switches for Labs features (no row = off)
CREATE TABLE "sr_user_lab_feature" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"feature" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "sr_user_lab_feature_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "sr_user_lab_feature_user_id_feature_key" ON "sr_user_lab_feature"("user_id", "feature");

CREATE INDEX "sr_user_lab_feature_feature_enabled_idx" ON "sr_user_lab_feature"("feature", "enabled");
14 changes: 13 additions & 1 deletion src/const/err.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export enum ErrorName {
IMPORT_OTHER_TIMEOUT = 'IMPORT_OTHER_TIMEOUT',
BOOKMARK_OVERVIEW_CONTENT_ERROR = 'BOOKMARK_OVERVIEW_CONTENT_ERROR',
SYNC_TABLE_RULE_ERROR = 'SYNC_TABLE_RULE_ERROR',
SYNC_TABLE_TAG_NAME_ERROR = 'SYNC_TABLE_TAG_NAME_ERROR'
SYNC_TABLE_TAG_NAME_ERROR = 'SYNC_TABLE_TAG_NAME_ERROR',
LAB_FEATURE_DISABLED = 'LAB_FEATURE_DISABLED'
}

const translations: { [key in Language]: Partial<Record<ErrorName, string>> } = {
Expand All @@ -91,6 +92,7 @@ const translations: { [key in Language]: Partial<Record<ErrorName, string>> } =
[ErrorName.TRASH_REVERT_BOOKMARK_FAIL]: '移出垃圾篓失败',
[ErrorName.BOOKMARK_NOT_FOUND]: '书签未找到',
[ErrorName.BLOCK_TARGET_URL]: '目标网址被阻止',
[ErrorName.LAB_FEATURE_DISABLED]: '{feature}还在实验室里,请到设置页打开后再保存',
[ErrorName.PROHIBITED_CONTENT]: '处理失败:内容被禁止',
[ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: '该内容类型不支持分享',
[ErrorName.CREATE_BOOKMARK_FAIL]: '创建书签失败',
Expand Down Expand Up @@ -139,6 +141,7 @@ const translations: { [key in Language]: Partial<Record<ErrorName, string>> } =
[ErrorName.TRASH_REVERT_BOOKMARK_FAIL]: 'Revert bookmark failed',
[ErrorName.BOOKMARK_NOT_FOUND]: 'Bookmark not found',
[ErrorName.BLOCK_TARGET_URL]: 'Blocked target url',
[ErrorName.LAB_FEATURE_DISABLED]: '{feature} are still in Labs. Turn it on in Settings, then save again',
[ErrorName.PROHIBITED_CONTENT]: 'Processing failed: prohibited content',
[ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: 'This type of content cannot be shared',
[ErrorName.CREATE_BOOKMARK_FAIL]: 'Create bookmark failed',
Expand Down Expand Up @@ -198,6 +201,7 @@ const translations: { [key in Language]: Partial<Record<ErrorName, string>> } =
[ErrorName.SERVER_ERROR]: 'Error interno del servidor',
[ErrorName.GOOGLE_SSO_ERROR]: 'Error de Google SSO',
[ErrorName.PROHIBITED_CONTENT]: 'Error de procesamiento: contenido prohibido',
[ErrorName.LAB_FEATURE_DISABLED]: '{feature} todavía están en el Laboratorio. Actívalo en Ajustes y vuelve a guardar',
[ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: 'Este tipo de contenido no se puede compartir'
}
}
Expand Down Expand Up @@ -292,3 +296,11 @@ export const ImportOtherTimeoutError = (): MultiLangError => NewError(ErrorName.
export const BookmarkOverviewContentError = (): MultiLangError => NewError(ErrorName.BOOKMARK_OVERVIEW_CONTENT_ERROR, 500)
export const SyncTableRuleError = (): MultiLangError => NewError(ErrorName.SYNC_TABLE_RULE_ERROR, 400)
export const SyncTableTagNameError = (): MultiLangError => NewError(ErrorName.SYNC_TABLE_TAG_NAME_ERROR, 400)
/** Message carries the feature's display name so clients can show it as-is. */
export const LabFeatureDisabledError = (featureName: { [lang in Language]?: string }): MultiLangError => {
const messages = loadErrorMessages(ErrorName.LAB_FEATURE_DISABLED)
for (const lang of Object.keys(messages) as Language[]) {
messages[lang] = (messages[lang] || '').replace('{feature}', featureName[lang] || featureName.en || '')
}
return new MultiLangError(ErrorName.LAB_FEATURE_DISABLED, 400, messages)
}
16 changes: 14 additions & 2 deletions src/di/generated/dependency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import { VectorizeRepo } from '../../infra/repository/dbVectorize'
import { MarkRepo } from '../../infra/repository/dbMark'
import { UserRepo } from '../../infra/repository/dbUser'
import { NotificationMessage } from '../../infra/message/notification'
import { LabRepo } from '../../infra/repository/dbLab'
import { ReportRepo } from '../../infra/repository/dbReport'
import { LabService } from '../../domain/lab'
import { BookmarkService } from '../../domain/bookmark'
import { TagService } from '../../domain/tag'
import { MarkService } from '../../domain/mark'
Expand Down Expand Up @@ -88,6 +90,10 @@ container.register(ImportService, {
)
})

container.register(LabService, {
useFactory: container => new LabService(container.resolve(LabRepo))
})

container.register(MarkService, {
useFactory: container => new MarkService(container.resolve(BookmarkRepo), container.resolve(MarkRepo), container.resolve(UserRepo))
})
Expand Down Expand Up @@ -117,7 +123,8 @@ container.register(UserService, {
new UserService(
container.resolve(UserRepo),
lazy(() => container.resolve(BucketClient)),
container.resolve(ReportRepo)
container.resolve(ReportRepo),
container.resolve(LabService)
)
})

Expand Down Expand Up @@ -204,6 +211,10 @@ container.register(BookmarkSearchRepo, {
useFactory: container => new BookmarkSearchRepo(lazy(() => container.resolve(PRISIMA_FULLTEXT_CLIENT)))
})

container.register(LabRepo, {
useFactory: container => new LabRepo(lazy(() => container.resolve(PRISIMA_HYPERDRIVE_CLIENT)))
})

container.register(MarkRepo, {
useFactory: container =>
new MarkRepo(
Expand Down Expand Up @@ -285,7 +296,8 @@ container.register(TagController, {
})

container.register(UserController, {
useFactory: container => new UserController(container.resolve(UserService), container.resolve(BookmarkService), container.resolve(NotificationService))
useFactory: container =>
new UserController(container.resolve(UserService), container.resolve(BookmarkService), container.resolve(NotificationService), container.resolve(LabService))
})

container.register(DatabaseRegistry, {
Expand Down
4 changes: 4 additions & 0 deletions src/di/generated/readerRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ export function getRouter(container: Container) {
const controller = container.resolve(UserController)
return await controller.handleDisableUserSettingRequest(ctx, req)
})
router.get('/v1/user/labs', async (req: Request, ctx: ContextManager) => {
const controller = container.resolve(UserController)
return await controller.handleUserLabsRequest(ctx, req)
})
router.get('/v1/user/userinfo', async (req: Request, ctx: ContextManager) => {
const controller = container.resolve(UserController)
return await controller.handleUserInfoRequest(ctx, req)
Expand Down
100 changes: 100 additions & 0 deletions src/domain/lab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { inject, injectable } from '../decorators/di'
import { LabRepo } from '../infra/repository/dbLab'
import { ErrorName, ErrorParam, LabFeatureDisabledError } from '../const/err'
import { MultiLangError, Language } from '../utils/multiLangError'
import { ContextManager } from '../utils/context'

export type LabFeatureStatus = 'active' | 'graduated' | 'retired'

export interface LabFeatureDef {
status: LabFeatureStatus
/** Display name per language, spliced into the "still in Labs" error message */
name: { [lang in Language]?: string }
/** ISO date the feature left Labs; the settings page hides graduated rows after a while */
graduatedAt?: string
}

export interface LabFeatureItem {
key: string
status: Exclude<LabFeatureStatus, 'retired'>
enabled: boolean
enabled_at: string | null
}

/**
* Labs: per-user switches for features that are still being tried out.
*
* The registry is empty in the open-source build. A hosted backend subclasses this
* service and overrides `features()` (and `gatedFeatureForUrl()` to block saves).
*/
@injectable()
export class LabService {
constructor(@inject(LabRepo) private labRepo: LabRepo) {}

/** Feature registry. Keys are what clients send as `lab:<key>`. */
protected features(): Record<string, LabFeatureDef> {
return {}
}

/** Which Labs feature (if any) a save of this URL needs. null = not gated. */
protected gatedFeatureForUrl(_url: string): string | null {
return null
}

/** Rows for the settings page: active and graduated only. */
public async listForUser(userId: number): Promise<LabFeatureItem[]> {
const defs = this.features()
const keys = Object.keys(defs).filter(key => defs[key].status !== 'retired')
if (keys.length === 0) return []

const rows = await this.labRepo.listByUser(userId)
const byKey = new Map(rows.map(row => [row.feature, row]))

return keys.map(key => {
const def = defs[key]
const row = byKey.get(key)
const enabled = def.status === 'graduated' ? true : (row?.enabled ?? false)
return {
key,
status: def.status as Exclude<LabFeatureStatus, 'retired'>,
enabled,
enabled_at: row?.enabled ? row.updated_at.toISOString() : null
}
})
}

/** Unknown or retired keys are rejected; graduated ones are written but never read. */
public async setEnabled(userId: number, key: string, enabled: boolean): Promise<void> {
const def = this.features()[key]
if (!def || def.status === 'retired') throw ErrorParam()
await this.labRepo.upsert(userId, key, enabled)
}

public async isEnabled(userId: number, key: string): Promise<boolean> {
const def = this.features()[key]
if (!def || def.status === 'retired') return false
if (def.status === 'graduated') return true
return this.labRepo.isEnabled(userId, key)
}

/** Throws LAB_FEATURE_DISABLED when the URL needs a Labs feature the user has not turned on. */
public async assertUrlAllowed(ctx: ContextManager, url: string): Promise<void> {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return // invalid URLs are reported by the existing save logic
}
const key = this.gatedFeatureForUrl(parsed.toString())
if (!key) return
if (!(await this.isEnabled(ctx.getUserId(), key))) throw this.disabledError(key)
}

public disabledError(key: string): MultiLangError {
return LabFeatureDisabledError(this.features()[key]?.name ?? { en: key })
}

public static isLabDisabledError(e: unknown): boolean {
return e instanceof MultiLangError && e.name === ErrorName.LAB_FEATURE_DISABLED
}
}
10 changes: 9 additions & 1 deletion src/domain/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { LazyInstance } from '../decorators/lazy'
import { UserRepo } from '../infra/repository/dbUser'
import { BucketClient } from '../infra/repository/bucketClient'
import { ReportRepo } from '../infra/repository/dbReport'
import { LabService } from './lab'

export interface userShareCollectInfo {
show_name: string
Expand Down Expand Up @@ -107,7 +108,8 @@ export class UserService {
constructor(
@inject(UserRepo) private userRepo: UserRepo,
@inject(BucketClient) private bucketClient: LazyInstance<BucketClient>,
@inject(ReportRepo) private reportRepo: ReportRepo
@inject(ReportRepo) private reportRepo: ReportRepo,
@inject(LabService) private labService: LabService
) {}

/**
Expand Down Expand Up @@ -335,6 +337,12 @@ export class UserService {
public async enableUserSetting(ctx: ContextManager, setting: string, enable: boolean): Promise<string | userShareCollectInfo> {
// const user = await this.userRepo.getInfoByUserId(ctx.getUserId())

// Labs switches share this endpoint: key "lab:<feature>"
if (setting.startsWith('lab:')) {
await this.labService.setEnabled(ctx.getUserId(), setting.slice(4), enable)
return 'ok'
}

if (setting === 'mail_collect' && !enable) {
await this.userRepo.unbindPlatform(ctx.getUserId(), platformBindType.EMAIL)
}
Expand Down
13 changes: 12 additions & 1 deletion src/handler/http/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { inject } from '../../decorators/di'
import { BookmarkService } from '../../domain/bookmark'
import { NotificationService } from '../../domain/notification'
import { UserService } from '../../domain/user'
import { LabService } from '../../domain/lab'

@Controller('/v1/user')
export class UserController {
constructor(
@inject(UserService) private userService: UserService,
@inject(BookmarkService) private bookmarkService: BookmarkService,
@inject(NotificationService) private notificationService: NotificationService
@inject(NotificationService) private notificationService: NotificationService,
@inject(LabService) private labService: LabService
) {}

/**
Expand Down Expand Up @@ -116,6 +118,15 @@ export class UserController {
return Successed(res)
}

/**
* 实验室功能列表(含当前用户的开关状态)
*/
@Get('/labs')
public async handleUserLabsRequest(ctx: ContextManager, request: Request) {
const features = await this.labService.listForUser(ctx.getUserId())
return Successed({ features })
}

/**
* 获取用户信息
*/
Expand Down
38 changes: 38 additions & 0 deletions src/infra/repository/dbLab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { inject, singleton } from '../../decorators/di'
import { PRISIMA_HYPERDRIVE_CLIENT } from '../../const/symbol'
import type { LazyInstance } from '../../decorators/lazy'
import { PrismaClient as HyperdrivePrismaClient } from '@prisma/hyperdrive-client'

export interface userLabFeaturePO {
id: number
user_id: number
feature: string
enabled: boolean
created_at: Date
updated_at: Date
}

@singleton()
export class LabRepo {
constructor(@inject(PRISIMA_HYPERDRIVE_CLIENT) private prismaPg: LazyInstance<HyperdrivePrismaClient>) {}

public async listByUser(userId: number): Promise<userLabFeaturePO[]> {
return this.prismaPg().sr_user_lab_feature.findMany({ where: { user_id: userId } })
}

public async isEnabled(userId: number, feature: string): Promise<boolean> {
const row = await this.prismaPg().sr_user_lab_feature.findUnique({
where: { user_id_feature: { user_id: userId, feature } },
select: { enabled: true }
})
return row?.enabled ?? false
}

public async upsert(userId: number, feature: string, enabled: boolean): Promise<userLabFeaturePO> {
return this.prismaPg().sr_user_lab_feature.upsert({
where: { user_id_feature: { user_id: userId, feature } },
create: { user_id: userId, feature, enabled },
update: { enabled }
})
}
}
Loading
Loading