feat: allow administrator deletion and remove icon source field
This commit is contained in:
1 parent
a5f1236d44
commit
2db0c75498
18 files changed
+367
-27
No files matched your search
@@ -0,0 +1 @@
|
||||
ALTER TABLE `Icon` DROP COLUMN `source`;
|
||||
@@ -325,8 +325,6 @@ model Icon {
|
||||
hash String @db.Char(64)
|
||||
/// 规范化静态 PNG 图片二进制
|
||||
data Bytes @db.MediumBlob
|
||||
/// 可选公开来源网址
|
||||
source String? @db.VarChar(500)
|
||||
/// 记录创建时间,UTC
|
||||
createdAt DateTime @default(now())
|
||||
positions Position[]
|
||||
|
||||
@@ -26,10 +26,9 @@ async function main() {
|
||||
name: item.name,
|
||||
shared: true,
|
||||
data,
|
||||
source: item.source,
|
||||
hash: createHash('sha256').update(data).digest('hex'),
|
||||
},
|
||||
update: { data, source: item.source, hash: createHash('sha256').update(data).digest('hex') },
|
||||
update: { data, hash: createHash('sha256').update(data).digest('hex') },
|
||||
});
|
||||
}
|
||||
console.log(`Shared icon catalog ready: ${sources.length} icons.`);
|
||||
|
||||
@@ -74,6 +74,17 @@ async function main() {
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
// Verify the icon column removal migration against its former shape too.
|
||||
await connection.query('ALTER TABLE `Icon` ADD COLUMN `source` VARCHAR(500) NULL');
|
||||
await connection.query(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
'../prisma/migrations/20261005150000_remove_icon_source/migration.sql',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const build = run([require.resolve('typescript/bin/tsc')]);
|
||||
if (build.status !== 0) {
|
||||
console.log(build.stdout);
|
||||
|
||||
+50
-4
@@ -4,19 +4,21 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Req,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { adminCreateInput, adminUpdateInput } from './user-access';
|
||||
import { UserRequest, AuthService } from './auth';
|
||||
import { adminCreateInput, adminUpdateInput, adminDeleteInput } from './user-access';
|
||||
|
||||
const summary = {
|
||||
id: true,
|
||||
@@ -28,7 +30,10 @@ const summary = {
|
||||
} as const;
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(private db: Database) {}
|
||||
constructor(
|
||||
private db: Database,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
private async requireAdmin(userId: string) {
|
||||
const u = await this.db.user.findUnique({ where: { id: userId } });
|
||||
if (!u || u.role !== 'admin' || u.banned || u.mustChangePassword)
|
||||
@@ -107,6 +112,44 @@ export class AdminService {
|
||||
return result;
|
||||
});
|
||||
}
|
||||
async remove(r: UserRequest, id: string, body: unknown) {
|
||||
z.string().uuid().parse(id);
|
||||
const v = adminDeleteInput.parse(body);
|
||||
await this.requireAdmin(r.userId);
|
||||
this.auth.limit(r);
|
||||
if (id === r.userId) throw new ForbiddenException('不能删除当前登录的管理员账号');
|
||||
const verified = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(v.currentPassword, verified.passwordHash)))
|
||||
throw new ForbiddenException('当前密码错误');
|
||||
return this.db.serial(async (tx) => {
|
||||
// Share the lock order with role/ban changes to avoid concurrent loss of administrators.
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE role='admin' ORDER BY id FOR UPDATE`);
|
||||
const actor = await tx.user.findUnique({ where: { id: r.userId } });
|
||||
if (
|
||||
!actor ||
|
||||
actor.role !== 'admin' ||
|
||||
actor.banned ||
|
||||
actor.mustChangePassword ||
|
||||
actor.passwordHash !== verified.passwordHash
|
||||
)
|
||||
throw new ForbiddenException('管理员权限已变更');
|
||||
const target = await tx.user.findUnique({ where: { id } });
|
||||
if (!target) throw new NotFoundException('账号不存在');
|
||||
if (target.role !== 'admin') throw new BadRequestException('仅支持删除管理员账号');
|
||||
if (v.confirmationUsername !== target.username)
|
||||
throw new BadRequestException('确认账号名称不一致,请重新核对');
|
||||
if (
|
||||
!(await tx.user.count({
|
||||
where: { id: { not: id }, role: 'admin', banned: false, mustChangePassword: false },
|
||||
}))
|
||||
)
|
||||
throw new ForbiddenException('请先设置另一位已完成改密且未封禁的管理员');
|
||||
// Private images must not become ownerless; published shared images remain available.
|
||||
await tx.icon.deleteMany({ where: { ownerId: id, shared: false } });
|
||||
await tx.user.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
@Controller('api/admin/users')
|
||||
export class AdminController {
|
||||
@@ -120,4 +163,7 @@ export class AdminController {
|
||||
@Patch(':id') update(@Req() r: UserRequest, @Param('id') id: string, @Body() b: unknown) {
|
||||
return this.service.update(r, id, b);
|
||||
}
|
||||
@Delete(':id') remove(@Req() r: UserRequest, @Param('id') id: string, @Body() b: unknown) {
|
||||
return this.service.remove(r, id, b);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export class IconsBusinessService {
|
||||
const [items, total] = await this.db.$transaction([
|
||||
this.db.icon.findMany({
|
||||
where,
|
||||
select: { id: true, name: true, shared: true, source: true },
|
||||
select: { id: true, name: true, shared: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
skip: (index - 1) * 60,
|
||||
take: 60,
|
||||
@@ -127,7 +127,7 @@ export class IconsBusinessService {
|
||||
where: { ownerId_hash_shared: { ownerId: r.userId, hash, shared } },
|
||||
create: { name: v.name, ownerId: r.userId, shared, hash, data },
|
||||
update: {},
|
||||
select: { id: true, name: true, shared: true, source: true },
|
||||
select: { id: true, name: true, shared: true },
|
||||
});
|
||||
return icon;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { metalConfig, metalHoldingInput } from './metals';
|
||||
import { scheduleInput } from './schedules';
|
||||
import { z } from 'zod';
|
||||
import { loginInput, adminCreateInput, adminUpdateInput } from './user-access';
|
||||
import { loginInput, adminCreateInput, adminUpdateInput, adminDeleteInput } from './user-access';
|
||||
import {
|
||||
credentials,
|
||||
credentialChange,
|
||||
@@ -36,6 +36,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/auth/login': loginInput,
|
||||
'POST /api/admin/users': adminCreateInput,
|
||||
'PATCH /api/admin/users/{id}': adminUpdateInput,
|
||||
'DELETE /api/admin/users/{id}': adminDeleteInput,
|
||||
'PATCH /api/auth/credentials': credentialChange,
|
||||
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
|
||||
'POST /api/positions': positionInput,
|
||||
|
||||
@@ -9,6 +9,12 @@ export const loginInput = credentials.extend({
|
||||
.max(72)
|
||||
.refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'),
|
||||
});
|
||||
export const adminDeleteInput = z
|
||||
.object({
|
||||
confirmationUsername: credentials.shape.username,
|
||||
currentPassword: loginInput.shape.password,
|
||||
})
|
||||
.strict();
|
||||
export const adminCreateInput = credentials.extend({ role: roles.default('user') }).strict();
|
||||
export const adminUpdateInput = z
|
||||
.object({ role: roles.optional(), banned: z.boolean().optional() })
|
||||
|
||||
@@ -228,6 +228,156 @@ test(
|
||||
const unbanned = await call('/auth/login', 'POST', { username, password: regularPassword });
|
||||
assert.equal(unbanned.status, 201);
|
||||
assert.equal((await call('/overview', 'GET', undefined, unbanned.cookie)).status, 200);
|
||||
const originalDelete = { confirmationUsername: 'admin', currentPassword: password };
|
||||
assert.equal(
|
||||
(await call('/admin/users/' + initial.id, 'DELETE', originalDelete, cookie)).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/admin/users/' + initial.id, 'DELETE', originalDelete, unbanned.cookie))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const replacementName = 'replacement_' + randomUUID().slice(0, 10);
|
||||
const replacementInitial = 'Initial-' + randomUUID();
|
||||
const replacement = await call(
|
||||
'/admin/users',
|
||||
'POST',
|
||||
{ username: replacementName, password: replacementInitial, role: 'admin' },
|
||||
cookie,
|
||||
);
|
||||
assert.equal(replacement.status, 201);
|
||||
const firstLogin = await call('/auth/login', 'POST', {
|
||||
username: replacementName,
|
||||
password: replacementInitial,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'admin', currentPassword: replacementInitial },
|
||||
firstLogin.cookie,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
const replacementPassword = 'Changed-' + randomUUID();
|
||||
const replacementChanged = await call(
|
||||
'/auth/credentials',
|
||||
'PATCH',
|
||||
{ currentPassword: replacementInitial, newPassword: replacementPassword },
|
||||
firstLogin.cookie,
|
||||
);
|
||||
assert.equal(replacementChanged.status, 200);
|
||||
const replacementCookie = replacementChanged.cookie;
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'admin', currentPassword: 'wrong' },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'wrong-name', currentPassword: replacementPassword },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.ok(await db.user.findUnique({ where: { id: initial.id } }));
|
||||
const originalToken = await call(
|
||||
'/agent/tokens',
|
||||
'POST',
|
||||
{ name: 'original-admin', scopes: ['read'], days: 1, password },
|
||||
cookie,
|
||||
);
|
||||
assert.equal(originalToken.status, 201);
|
||||
const privateIcon = await db.icon.create({
|
||||
data: {
|
||||
name: 'private-delete',
|
||||
ownerId: initial.id,
|
||||
shared: false,
|
||||
hash: 'a'.repeat(64),
|
||||
data: Buffer.from('test'),
|
||||
},
|
||||
});
|
||||
const sharedIcon = await db.icon.create({
|
||||
data: {
|
||||
name: 'shared-keep',
|
||||
ownerId: initial.id,
|
||||
shared: true,
|
||||
hash: 'b'.repeat(64),
|
||||
data: Buffer.from('test'),
|
||||
},
|
||||
});
|
||||
const position = await db.position.create({
|
||||
data: {
|
||||
userId: initial.id,
|
||||
name: 'delete-fixture',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'bank',
|
||||
currency: 'CNY',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
await db.revision.create({
|
||||
data: {
|
||||
positionId: position.id,
|
||||
amount: '10',
|
||||
effectiveDate: new Date(),
|
||||
notes: '',
|
||||
reason: 'initial',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'admin', currentPassword: replacementPassword },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await db.user.findUnique({ where: { id: initial.id } }), null);
|
||||
assert.equal(await db.position.findUnique({ where: { id: position.id } }), null);
|
||||
assert.equal(await db.revision.count({ where: { positionId: position.id } }), 0);
|
||||
assert.equal(await db.icon.findUnique({ where: { id: privateIcon.id } }), null);
|
||||
assert.equal(
|
||||
(await db.icon.findUniqueOrThrow({ where: { id: sharedIcon.id } })).ownerId,
|
||||
null,
|
||||
);
|
||||
assert.equal((await call('/auth/me', 'GET', undefined, cookie)).status, 401);
|
||||
assert.equal(
|
||||
(await call('/auth/login', 'POST', { username: 'admin', password })).status,
|
||||
401,
|
||||
);
|
||||
await assert.rejects(oauth.verifyAccessToken(originalToken.data.token));
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + replacement.data.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: replacementName, currentPassword: replacementPassword },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
await new AuthService(db as Database).onModuleInit();
|
||||
assert.equal(await db.user.count({ where: { username: 'admin' } }), 0);
|
||||
assert.equal((await call('/admin/users', 'GET', undefined, replacementCookie)).status, 200);
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
|
||||
assert.equal((await call('/icons')).status, 401);
|
||||
const own = await upload(a.cookie, '我的银行');
|
||||
assert.equal(own.status, 201);
|
||||
assert.equal('source' in own.data, false);
|
||||
const ownList = await call('/icons?q=' + encodeURIComponent('我的银行'), a.cookie);
|
||||
assert.equal('source' in ownList.data.items.find((i: any) => i.id === own.data.id), false);
|
||||
const sourceColumns: any[] = await db.$queryRawUnsafe(
|
||||
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'Icon' AND COLUMN_NAME = 'source'",
|
||||
);
|
||||
assert.equal(sourceColumns.length, 0);
|
||||
assert.equal((await upload(a.cookie, '同一图片')).data.id, own.data.id);
|
||||
assert.equal((await call('/icons/' + own.data.id + '/image', b.cookie)).status, 404);
|
||||
assert.equal(
|
||||
|
||||
Reference in new issue
Block a user