@meith/db 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/db",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,35 +22,35 @@
22
22
  "dependencies": {
23
23
  "drizzle-orm": "^0.45.2",
24
24
  "postgres": "^3.4.7",
25
- "@meith/accounts": "0.22.0",
26
- "@meith/admin": "0.22.0",
27
- "@meith/antispam": "0.22.0",
28
- "@meith/authorization": "0.22.0",
29
- "@meith/attachments": "0.22.0",
30
- "@meith/api": "0.22.0",
31
- "@meith/avatars": "0.22.0",
32
- "@meith/core": "0.22.0",
33
- "@meith/drafts": "0.22.0",
34
- "@meith/events": "0.22.0",
35
- "@meith/forums": "0.22.0",
36
- "@meith/groups": "0.22.0",
37
- "@meith/markdown": "0.22.0",
38
- "@meith/i18n": "0.22.0",
39
- "@meith/marketplace": "0.22.0",
40
- "@meith/messages": "0.22.0",
41
- "@meith/moderation": "0.22.0",
42
- "@meith/plugin-kit": "0.22.0",
43
- "@meith/polls": "0.22.0",
44
- "@meith/notifications": "0.22.0",
45
- "@meith/relations": "0.22.0",
46
- "@meith/profile-fields": "0.22.0",
47
- "@meith/settings": "0.22.0",
48
- "@meith/search": "0.22.0",
49
- "@meith/signatures": "0.22.0",
50
- "@meith/threads": "0.22.0",
51
- "@meith/tasks": "0.22.0",
52
- "@meith/subscriptions": "0.22.0",
53
- "@meith/reputation": "0.22.0"
25
+ "@meith/accounts": "0.23.1",
26
+ "@meith/admin": "0.23.1",
27
+ "@meith/antispam": "0.23.1",
28
+ "@meith/api": "0.23.1",
29
+ "@meith/attachments": "0.23.1",
30
+ "@meith/authorization": "0.23.1",
31
+ "@meith/avatars": "0.23.1",
32
+ "@meith/core": "0.23.1",
33
+ "@meith/drafts": "0.23.1",
34
+ "@meith/events": "0.23.1",
35
+ "@meith/forums": "0.23.1",
36
+ "@meith/groups": "0.23.1",
37
+ "@meith/i18n": "0.23.1",
38
+ "@meith/markdown": "0.23.1",
39
+ "@meith/marketplace": "0.23.1",
40
+ "@meith/messages": "0.23.1",
41
+ "@meith/moderation": "0.23.1",
42
+ "@meith/notifications": "0.23.1",
43
+ "@meith/plugin-kit": "0.23.1",
44
+ "@meith/polls": "0.23.1",
45
+ "@meith/profile-fields": "0.23.1",
46
+ "@meith/relations": "0.23.1",
47
+ "@meith/reputation": "0.23.1",
48
+ "@meith/search": "0.23.1",
49
+ "@meith/settings": "0.23.1",
50
+ "@meith/signatures": "0.23.1",
51
+ "@meith/subscriptions": "0.23.1",
52
+ "@meith/tasks": "0.23.1",
53
+ "@meith/threads": "0.23.1"
54
54
  },
55
55
  "devDependencies": {
56
56
  "drizzle-kit": "^0.31.6",
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { BanFilterAdminRepository } from '@meith/accounts'
4
+ import { BAN_FILTER_PATTERN_MAX, BAN_FILTER_WILDCARD_MAX } from '@meith/accounts'
5
+ import { ValidationError } from '@meith/core'
6
+
7
+ export const CONTRACT_AUTHOR = 4242
8
+
9
+ export type BanFilterRepositoryFactory = () => Promise<BanFilterAdminRepository>
10
+
11
+ export function banFilterRepositoryContract(name: string, make: BanFilterRepositoryFactory): void {
12
+ describe(`BanFilterAdminRepository contract: ${name}`, () => {
13
+ it('round-trips the pattern, the note and who added it', async () => {
14
+ const filters = await make()
15
+ const id = await filters.create({
16
+ type: 'email',
17
+ pattern: '*@blocked.example',
18
+ note: 'the sign-up wave in March',
19
+ createdByUserId: CONTRACT_AUTHOR,
20
+ })
21
+
22
+ const stored = await filters.listForAdmin()
23
+
24
+ expect(stored).toHaveLength(1)
25
+ expect(stored[0]).toMatchObject({
26
+ id,
27
+ type: 'email',
28
+ pattern: '*@blocked.example',
29
+ note: 'the sign-up wave in March',
30
+ createdByUserId: CONTRACT_AUTHOR,
31
+ })
32
+ expect(stored[0]?.createdAt).toBeInstanceOf(Date)
33
+ })
34
+
35
+ it('lists the newest filter first', async () => {
36
+ const filters = await make()
37
+ await filters.create({ type: 'username', pattern: 'first*', createdByUserId: null })
38
+ await filters.create({ type: 'username', pattern: 'second*', createdByUserId: null })
39
+
40
+ expect((await filters.listForAdmin()).map((row) => row.pattern)).toEqual([
41
+ 'second*',
42
+ 'first*',
43
+ ])
44
+ })
45
+
46
+ it('keeps an unwritten note and an unnamed author as null', async () => {
47
+ const filters = await make()
48
+ await filters.create({
49
+ type: 'ip',
50
+ pattern: '192.0.2.*',
51
+ note: ' ',
52
+ createdByUserId: null,
53
+ })
54
+
55
+ expect((await filters.listForAdmin())[0]).toMatchObject({
56
+ note: null,
57
+ createdByUserId: null,
58
+ })
59
+ })
60
+
61
+ it('stores the pattern without the whitespace around it', async () => {
62
+ const filters = await make()
63
+ await filters.create({ type: 'username', pattern: ' spammer* ', createdByUserId: null })
64
+
65
+ expect((await filters.listForAdmin())[0]?.pattern).toBe('spammer*')
66
+ })
67
+
68
+ it('refuses a second filter of the same type and pattern', async () => {
69
+ const filters = await make()
70
+ await filters.create({ type: 'email', pattern: '*@blocked.example', createdByUserId: null })
71
+
72
+ await expect(
73
+ filters.create({ type: 'email', pattern: '*@blocked.example', createdByUserId: null }),
74
+ ).rejects.toThrow(ValidationError)
75
+
76
+ expect(await filters.listForAdmin()).toHaveLength(1)
77
+ })
78
+
79
+ it('holds the same pattern under a different type', async () => {
80
+ const filters = await make()
81
+ await filters.create({ type: 'email', pattern: 'shared*', createdByUserId: null })
82
+
83
+ await expect(
84
+ filters.create({ type: 'username', pattern: 'shared*', createdByUserId: null }),
85
+ ).resolves.toEqual(expect.any(Number))
86
+ })
87
+
88
+ it('refuses an empty pattern before writing anything', async () => {
89
+ const filters = await make()
90
+
91
+ await expect(
92
+ filters.create({ type: 'username', pattern: ' ', createdByUserId: null }),
93
+ ).rejects.toThrow(ValidationError)
94
+
95
+ expect(await filters.listForAdmin()).toHaveLength(0)
96
+ })
97
+
98
+ it('refuses a pattern longer than the cap before writing anything', async () => {
99
+ const filters = await make()
100
+
101
+ await expect(
102
+ filters.create({
103
+ type: 'username',
104
+ pattern: 'a'.repeat(BAN_FILTER_PATTERN_MAX + 1),
105
+ createdByUserId: null,
106
+ }),
107
+ ).rejects.toThrow(ValidationError)
108
+
109
+ expect(await filters.listForAdmin()).toHaveLength(0)
110
+ })
111
+
112
+ it('refuses a pattern with more wildcards than the cap before writing anything', async () => {
113
+ const filters = await make()
114
+
115
+ await expect(
116
+ filters.create({
117
+ type: 'username',
118
+ pattern: `a${'*a'.repeat(BAN_FILTER_WILDCARD_MAX + 1)}`,
119
+ createdByUserId: null,
120
+ }),
121
+ ).rejects.toThrow(ValidationError)
122
+
123
+ expect(await filters.listForAdmin()).toHaveLength(0)
124
+ })
125
+
126
+ it('removes a filter', async () => {
127
+ const filters = await make()
128
+ const id = await filters.create({
129
+ type: 'email',
130
+ pattern: '*@blocked.example',
131
+ createdByUserId: null,
132
+ })
133
+
134
+ await filters.remove(id)
135
+
136
+ expect(await filters.listForAdmin()).toHaveLength(0)
137
+ expect(await filters.listAll()).toHaveLength(0)
138
+ })
139
+
140
+ it('treats removing a filter that is already gone as done', async () => {
141
+ const filters = await make()
142
+ const id = await filters.create({
143
+ type: 'email',
144
+ pattern: '*@blocked.example',
145
+ createdByUserId: null,
146
+ })
147
+
148
+ await filters.remove(id)
149
+
150
+ await expect(filters.remove(id)).resolves.toBeUndefined()
151
+ })
152
+
153
+ it('gives the matcher only what it needs to match on', async () => {
154
+ const filters = await make()
155
+ const id = await filters.create({
156
+ type: 'email',
157
+ pattern: '*@blocked.example',
158
+ note: 'a note the matcher has no use for',
159
+ createdByUserId: CONTRACT_AUTHOR,
160
+ })
161
+
162
+ expect(await filters.listAll()).toEqual([{ id, type: 'email', pattern: '*@blocked.example' }])
163
+ })
164
+ })
165
+ }
package/src/ban-repos.ts CHANGED
@@ -1,13 +1,18 @@
1
- import { and, asc, eq, isNotNull, isNull, lte, sql } from 'drizzle-orm'
2
-
3
- import type {
4
- BanFilter,
5
- BanFilterRepository,
6
- BanFilterType,
7
- BanRecord,
8
- BanRepository,
9
- CreateBanInput,
1
+ import { and, asc, desc, eq, isNotNull, isNull, lte, sql } from 'drizzle-orm'
2
+
3
+ import {
4
+ assertUsableFilter,
5
+ type BanFilter,
6
+ type BanFilterAdminRepository,
7
+ type BanFilterRecord,
8
+ type BanFilterType,
9
+ type BanRecord,
10
+ type BanRepository,
11
+ type CreateBanFilterInput,
12
+ type CreateBanInput,
10
13
  } from '@meith/accounts'
14
+ import { ValidationError } from '@meith/core'
15
+ import { msg } from '@meith/i18n'
11
16
 
12
17
  import type { Database } from './client'
13
18
  import { banFilters, bans, rememberTokens, sessions, users } from './schema'
@@ -133,7 +138,7 @@ export class PostgresBanRepository implements BanRepository {
133
138
  }
134
139
  }
135
140
 
136
- export class PostgresBanFilterRepository implements BanFilterRepository {
141
+ export class PostgresBanFilterRepository implements BanFilterAdminRepository {
137
142
  constructor(private readonly db: Database) {}
138
143
 
139
144
  async listAll(): Promise<readonly BanFilter[]> {
@@ -143,4 +148,45 @@ export class PostgresBanFilterRepository implements BanFilterRepository {
143
148
 
144
149
  return rows.map((r) => ({ id: r.id, type: r.type as BanFilterType, pattern: r.pattern }))
145
150
  }
151
+
152
+ async listForAdmin(): Promise<readonly BanFilterRecord[]> {
153
+ const rows = await this.db
154
+ .select({
155
+ id: banFilters.id,
156
+ type: banFilters.type,
157
+ pattern: banFilters.pattern,
158
+ note: banFilters.note,
159
+ createdByUserId: banFilters.createdByUserId,
160
+ createdAt: banFilters.createdAt,
161
+ })
162
+ .from(banFilters)
163
+ .orderBy(desc(banFilters.id))
164
+
165
+ return rows.map((r) => ({ ...r, type: r.type as BanFilterType }))
166
+ }
167
+
168
+ async create(input: CreateBanFilterInput): Promise<number> {
169
+ assertUsableFilter(input.type, input.pattern)
170
+
171
+ const note = input.note?.trim()
172
+
173
+ const [row] = await this.db
174
+ .insert(banFilters)
175
+ .values({
176
+ type: input.type,
177
+ pattern: input.pattern.trim(),
178
+ note: note === undefined || note === '' ? null : note,
179
+ createdByUserId: input.createdByUserId,
180
+ })
181
+ .onConflictDoNothing({ target: [banFilters.type, banFilters.pattern] })
182
+ .returning({ id: banFilters.id })
183
+
184
+ if (row === undefined) throw new ValidationError(msg('error.accounts.ban-filter-already-held'))
185
+
186
+ return row.id
187
+ }
188
+
189
+ async remove(id: number): Promise<void> {
190
+ await this.db.delete(banFilters).where(eq(banFilters.id, id))
191
+ }
146
192
  }