chore: drop old client support (#14369)

This commit is contained in:
DarkSky
2026-02-05 02:49:33 +08:00
committed by GitHub
parent de29e8300a
commit 403f16b404
103 changed files with 3293 additions and 997 deletions
@@ -1,3 +1,4 @@
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { createModule } from '../../__tests__/create-module';
@@ -23,8 +24,16 @@ test('should create access token', async t => {
t.is(token.userId, user.id);
t.is(token.name, 'test');
t.truthy(token.token);
t.true(token.token.startsWith('ut_'));
t.truthy(token.createdAt);
t.is(token.expiresAt, null);
const row = await module.get(PrismaClient).accessToken.findUnique({
where: { id: token.id },
});
t.truthy(row);
t.regex(row!.token, /^[0-9a-f]{64}$/);
t.not(row!.token, token.token);
});
test('should create access token with expiration', async t => {
@@ -50,6 +59,22 @@ test('should list access tokens without token value', async t => {
t.is(listed[0].token, undefined);
});
test('should not reveal access token value after creation', async t => {
const user = await module.create(Mockers.User);
const token = await models.accessToken.create({
userId: user.id,
name: 'test',
});
const listed = await models.accessToken.list(user.id, true);
const found = listed.find(item => item.id === token.id);
t.truthy(found);
t.is(found!.token, '[REDACTED]');
t.not(found!.token, token.token);
});
test('should be able to revoke access token', async t => {
const user = await module.create(Mockers.User);
const token = await module.create(Mockers.AccessToken, { userId: user.id });
@@ -62,7 +87,10 @@ test('should be able to revoke access token', async t => {
test('should be able to get access token by token value', async t => {
const user = await module.create(Mockers.User);
const token = await module.create(Mockers.AccessToken, { userId: user.id });
const token = await models.accessToken.create({
userId: user.id,
name: 'test',
});
const found = await models.accessToken.getByToken(token.token);
t.is(found?.id, token.id);
@@ -72,8 +100,9 @@ test('should be able to get access token by token value', async t => {
test('should not get expired access token', async t => {
const user = await module.create(Mockers.User);
const token = await module.create(Mockers.AccessToken, {
const token = await models.accessToken.create({
userId: user.id,
name: 'test',
expiresAt: Due.before('1s'),
});
@@ -3,43 +3,53 @@ import { Injectable } from '@nestjs/common';
import { CryptoHelper } from '../base';
import { BaseModel } from './base';
const REDACTED_TOKEN = '[REDACTED]';
export interface CreateAccessTokenInput {
userId: string;
name: string;
expiresAt?: Date | null;
}
type UserAccessToken = {
id: string;
name: string;
createdAt: Date;
expiresAt: Date | null;
};
@Injectable()
export class AccessTokenModel extends BaseModel {
constructor(private readonly crypto: CryptoHelper) {
super();
}
async list(userId: string, revealed?: false): Promise<UserAccessToken[]>;
async list(
userId: string,
revealed: true
): Promise<(UserAccessToken & { token: string })[]>;
async list(userId: string, revealed: boolean = false) {
return await this.db.accessToken.findMany({
select: {
id: true,
name: true,
createdAt: true,
expiresAt: true,
token: revealed,
},
where: {
userId,
},
const tokens = await this.db.accessToken.findMany({
select: { id: true, name: true, createdAt: true, expiresAt: true },
where: { userId },
});
if (!revealed) return tokens;
return tokens.map(row => ({ ...row, token: REDACTED_TOKEN }));
}
async create(input: CreateAccessTokenInput) {
let token = 'ut_' + this.crypto.randomBytes(40).toString('hex');
token = token.substring(0, 40);
const token = `ut_${this.crypto.randomBytes(32).toString('base64url')}`;
const tokenHash = this.crypto.sha256(token).toString('hex');
return await this.db.accessToken.create({
data: {
token,
...input,
},
const created = await this.db.accessToken.create({
data: { token: tokenHash, ...input },
});
// NOTE: we only return the plaintext token once, at creation time.
return { ...created, token };
}
async revoke(id: string, userId: string) {
@@ -52,20 +62,27 @@ export class AccessTokenModel extends BaseModel {
}
async getByToken(token: string) {
return await this.db.accessToken.findUnique({
where: {
token,
OR: [
{
expiresAt: null,
},
{
expiresAt: {
gt: new Date(),
},
},
],
},
const tokenHash = this.crypto.sha256(token).toString('hex');
const condition = [{ expiresAt: null }, { expiresAt: { gt: new Date() } }];
const found = await this.db.accessToken.findUnique({
where: { token: tokenHash, OR: condition },
});
if (found) return found;
// Compatibility: lazy-migrate old plaintext tokens in DB.
const legacy = await this.db.accessToken.findUnique({
where: { token, OR: condition },
});
if (!legacy) return null;
await this.db.accessToken.update({
where: { id: legacy.id },
data: { token: tokenHash },
});
return { ...legacy, token: tokenHash };
}
}
+2 -2
View File
@@ -131,7 +131,7 @@ export class DocModel extends BaseModel {
},
});
if (count > 0) {
this.logger.log(
this.logger.verbose(
`Deleted ${count} updates for workspace ${workspaceId} doc ${docId}`
);
}
@@ -159,7 +159,7 @@ export class DocModel extends BaseModel {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "size", "created_at", "updated_at", "created_by", "updated_by")
VALUES (${spaceId}, ${docId}, ${blob}, ${size}, DEFAULT, ${updatedAt}, ${editorId}, ${editorId})
VALUES (${spaceId}, ${docId}, ${blob}, ${size}, ${updatedAt}, ${updatedAt}, ${editorId}, ${editorId})
ON CONFLICT ("workspace_id", "guid")
DO UPDATE SET "blob" = ${blob}, "size" = ${size}, "updated_at" = ${updatedAt}, "updated_by" = ${editorId}
WHERE "snapshots"."workspace_id" = ${spaceId} AND "snapshots"."guid" = ${docId} AND "snapshots"."updated_at" <= ${updatedAt}
@@ -24,6 +24,7 @@ import { DocModel } from './doc';
import { DocUserModel } from './doc-user';
import { FeatureModel } from './feature';
import { HistoryModel } from './history';
import { MagicLinkOtpModel } from './magic-link-otp';
import { NotificationModel } from './notification';
import { MODELS_SYMBOL } from './provider';
import { SessionModel } from './session';
@@ -41,6 +42,7 @@ const MODELS = {
user: UserModel,
session: SessionModel,
verificationToken: VerificationTokenModel,
magicLinkOtp: MagicLinkOtpModel,
feature: FeatureModel,
workspace: WorkspaceModel,
userFeature: UserFeatureModel,
@@ -133,6 +135,7 @@ export * from './doc';
export * from './doc-user';
export * from './feature';
export * from './history';
export * from './magic-link-otp';
export * from './notification';
export * from './session';
export * from './user';
@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { CryptoHelper } from '../base';
import { BaseModel } from './base';
const MAX_OTP_ATTEMPTS = 10;
const OTP_TTL_IN_SEC = 30 * 60;
export type ConsumeMagicLinkOtpResult =
| { ok: true; token: string }
| { ok: false; reason: 'not_found' | 'expired' | 'invalid_otp' | 'locked' }
| { ok: false; reason: 'nonce_mismatch' };
@Injectable()
export class MagicLinkOtpModel extends BaseModel {
constructor(private readonly crypto: CryptoHelper) {
super();
}
private hash(otp: string) {
return this.crypto.sha256(otp).toString('hex');
}
async upsert(
email: string,
otp: string,
token: string,
clientNonce?: string
) {
const otpHash = this.hash(otp);
const expiresAt = new Date(Date.now() + OTP_TTL_IN_SEC * 1000);
await this.db.magicLinkOtp.upsert({
where: { email },
create: { email, otpHash, token, clientNonce, expiresAt, attempts: 0 },
update: { otpHash, token, clientNonce, expiresAt, attempts: 0 },
});
}
@Transactional()
async consume(
email: string,
otp: string,
clientNonce?: string
): Promise<ConsumeMagicLinkOtpResult> {
const now = new Date();
const otpHash = this.hash(otp);
const record = await this.db.magicLinkOtp.findUnique({ where: { email } });
if (!record) {
return { ok: false, reason: 'not_found' };
}
if (record.expiresAt <= now) {
await this.db.magicLinkOtp.delete({ where: { email } });
return { ok: false, reason: 'expired' };
}
if (record.clientNonce && record.clientNonce !== clientNonce) {
return { ok: false, reason: 'nonce_mismatch' };
}
if (record.attempts >= MAX_OTP_ATTEMPTS) {
await this.db.magicLinkOtp.delete({ where: { email } });
return { ok: false, reason: 'locked' };
}
const matches = this.crypto.compare(record.otpHash, otpHash);
if (!matches) {
const attempts = record.attempts + 1;
if (attempts >= MAX_OTP_ATTEMPTS) {
await this.db.magicLinkOtp.delete({ where: { email } });
return { ok: false, reason: 'locked' };
}
await this.db.magicLinkOtp.update({
where: { email },
data: { attempts },
});
return { ok: false, reason: 'invalid_otp' };
}
await this.db.magicLinkOtp.delete({ where: { email } });
return { ok: true, token: record.token };
}
}
@@ -14,6 +14,7 @@ export enum TokenType {
ChangeEmail,
ChangePassword,
Challenge,
OpenAppSignIn,
}
@Injectable()
@@ -302,6 +302,29 @@ export class WorkspaceUserModel extends BaseModel {
});
}
async hasSharedWorkspace(userId: string, otherUserId: string) {
if (userId === otherUserId) {
return true;
}
const shared = await this.db.workspaceUserRole.findFirst({
select: { id: true },
where: {
userId,
status: WorkspaceMemberStatus.Accepted,
workspace: {
permissions: {
some: {
userId: otherUserId,
},
},
},
},
});
return !!shared;
}
async paginate(workspaceId: string, pagination: PaginationInput) {
return await Promise.all([
this.db.workspaceUserRole.findMany({