@atproto/ozone 0.0.15 → 0.0.16
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/CHANGELOG.md +9 -0
- package/dist/api/proxied.d.ts +3 -0
- package/dist/auth-verifier.d.ts +72 -0
- package/dist/config/config.d.ts +9 -2
- package/dist/config/env.d.ts +4 -0
- package/dist/context.d.ts +3 -90
- package/dist/db/index.js.map +2 -2
- package/dist/index.js +29669 -29467
- package/dist/index.js.map +3 -3
- package/dist/lexicon/lexicons.d.ts +5 -0
- package/dist/lexicon/types/app/bsky/actor/defs.d.ts +1 -1
- package/dist/lexicon/types/com/atproto/admin/defs.d.ts +1 -0
- package/dist/mod-service/index.d.ts +16 -7
- package/dist/mod-service/views.d.ts +3 -3
- package/package.json +5 -5
- package/src/api/admin/createCommunicationTemplate.ts +2 -2
- package/src/api/admin/deleteCommunicationTemplate.ts +2 -2
- package/src/api/admin/emitModerationEvent.ts +23 -4
- package/src/api/admin/getModerationEvent.ts +1 -1
- package/src/api/admin/getRecord.ts +2 -2
- package/src/api/admin/getRepo.ts +2 -2
- package/src/api/admin/listCommunicationTemplates.ts +2 -2
- package/src/api/admin/queryModerationEvents.ts +1 -1
- package/src/api/admin/queryModerationStatuses.ts +1 -1
- package/src/api/admin/searchRepos.ts +1 -1
- package/src/api/admin/updateCommunicationTemplate.ts +2 -2
- package/src/api/index.ts +2 -0
- package/src/api/moderation/createReport.ts +2 -2
- package/src/api/proxied.ts +116 -0
- package/src/api/temp/fetchLabels.ts +2 -2
- package/src/api/well-known.ts +3 -3
- package/src/auth-verifier.ts +218 -0
- package/src/config/config.ts +18 -2
- package/src/config/env.ts +9 -1
- package/src/context.ts +24 -38
- package/src/daemon/context.ts +10 -4
- package/src/daemon/event-pusher.ts +3 -3
- package/src/lexicon/lexicons.ts +5 -0
- package/src/lexicon/types/app/bsky/actor/defs.ts +1 -1
- package/src/lexicon/types/com/atproto/admin/defs.ts +2 -0
- package/src/mod-service/index.ts +64 -7
- package/src/mod-service/views.ts +22 -18
- package/tests/moderation-events.test.ts +49 -0
- package/dist/auth.d.ts +0 -81
- package/src/auth.ts +0 -147
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import * as ui8 from 'uint8arrays'
|
|
3
|
+
import { IdResolver } from '@atproto/identity'
|
|
4
|
+
import { AuthRequiredError, verifyJwt } from '@atproto/xrpc-server'
|
|
5
|
+
|
|
6
|
+
type ReqCtx = {
|
|
7
|
+
req: express.Request
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type RoleOutput = {
|
|
11
|
+
credentials: {
|
|
12
|
+
type: 'role'
|
|
13
|
+
isAdmin: boolean
|
|
14
|
+
isModerator: boolean
|
|
15
|
+
isTriage: true
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ModeratorOutput = {
|
|
20
|
+
credentials: {
|
|
21
|
+
type: 'moderator'
|
|
22
|
+
aud: string
|
|
23
|
+
iss: string
|
|
24
|
+
isAdmin: boolean
|
|
25
|
+
isModerator: boolean
|
|
26
|
+
isTriage: true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type StandardOutput = {
|
|
31
|
+
credentials: {
|
|
32
|
+
type: 'standard'
|
|
33
|
+
aud: string
|
|
34
|
+
iss: string
|
|
35
|
+
isAdmin: boolean
|
|
36
|
+
isModerator: boolean
|
|
37
|
+
isTriage: boolean
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type NullOutput = {
|
|
42
|
+
credentials: {
|
|
43
|
+
type: 'none'
|
|
44
|
+
iss: null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AuthVerifierOpts = {
|
|
49
|
+
serviceDid: string
|
|
50
|
+
admins: string[]
|
|
51
|
+
moderators: string[]
|
|
52
|
+
triage: string[]
|
|
53
|
+
adminPassword: string
|
|
54
|
+
moderatorPassword: string
|
|
55
|
+
triagePassword: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class AuthVerifier {
|
|
59
|
+
serviceDid: string
|
|
60
|
+
admins: string[]
|
|
61
|
+
moderators: string[]
|
|
62
|
+
triage: string[]
|
|
63
|
+
private adminPassword: string
|
|
64
|
+
private moderatorPassword: string
|
|
65
|
+
private triagePassword: string
|
|
66
|
+
|
|
67
|
+
constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) {
|
|
68
|
+
this.serviceDid = opts.serviceDid
|
|
69
|
+
this.admins = opts.admins
|
|
70
|
+
this.moderators = opts.moderators
|
|
71
|
+
this.triage = opts.triage
|
|
72
|
+
this.adminPassword = opts.adminPassword
|
|
73
|
+
this.moderatorPassword = opts.moderatorPassword
|
|
74
|
+
this.triagePassword = opts.triagePassword
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
modOrRole = async (reqCtx: ReqCtx): Promise<ModeratorOutput | RoleOutput> => {
|
|
78
|
+
if (isBearerToken(reqCtx.req)) {
|
|
79
|
+
return this.moderator(reqCtx)
|
|
80
|
+
} else {
|
|
81
|
+
return this.role(reqCtx)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
moderator = async (reqCtx: ReqCtx): Promise<ModeratorOutput> => {
|
|
86
|
+
const creds = await this.standard(reqCtx)
|
|
87
|
+
if (!creds.credentials.isTriage) {
|
|
88
|
+
throw new AuthRequiredError('not a moderator account')
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
credentials: {
|
|
92
|
+
...creds.credentials,
|
|
93
|
+
type: 'moderator',
|
|
94
|
+
isTriage: true,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
standard = async (reqCtx: ReqCtx): Promise<StandardOutput> => {
|
|
100
|
+
const getSigningKey = async (
|
|
101
|
+
did: string,
|
|
102
|
+
forceRefresh: boolean,
|
|
103
|
+
): Promise<string> => {
|
|
104
|
+
const atprotoData = await this.idResolver.did.resolveAtprotoData(
|
|
105
|
+
did,
|
|
106
|
+
forceRefresh,
|
|
107
|
+
)
|
|
108
|
+
return atprotoData.signingKey
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const jwtStr = getJwtStrFromReq(reqCtx.req)
|
|
112
|
+
if (!jwtStr) {
|
|
113
|
+
throw new AuthRequiredError('missing jwt', 'MissingJwt')
|
|
114
|
+
}
|
|
115
|
+
const payload = await verifyJwt(jwtStr, this.serviceDid, getSigningKey)
|
|
116
|
+
const iss = payload.iss
|
|
117
|
+
const isAdmin = this.admins.includes(iss)
|
|
118
|
+
const isModerator = isAdmin || this.moderators.includes(iss)
|
|
119
|
+
const isTriage = isModerator || this.triage.includes(iss)
|
|
120
|
+
return {
|
|
121
|
+
credentials: {
|
|
122
|
+
type: 'standard',
|
|
123
|
+
iss,
|
|
124
|
+
aud: payload.aud,
|
|
125
|
+
isAdmin,
|
|
126
|
+
isModerator,
|
|
127
|
+
isTriage,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
standardOptional = async (
|
|
133
|
+
reqCtx: ReqCtx,
|
|
134
|
+
): Promise<StandardOutput | NullOutput> => {
|
|
135
|
+
if (isBearerToken(reqCtx.req)) {
|
|
136
|
+
return this.standard(reqCtx)
|
|
137
|
+
}
|
|
138
|
+
return this.nullCreds()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
standardOptionalOrRole = async (
|
|
142
|
+
reqCtx: ReqCtx,
|
|
143
|
+
): Promise<StandardOutput | RoleOutput | NullOutput> => {
|
|
144
|
+
if (isBearerToken(reqCtx.req)) {
|
|
145
|
+
return this.standard(reqCtx)
|
|
146
|
+
} else if (isBasicToken(reqCtx.req)) {
|
|
147
|
+
return this.role(reqCtx)
|
|
148
|
+
} else {
|
|
149
|
+
return this.nullCreds()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
role = async (reqCtx: ReqCtx): Promise<RoleOutput> => {
|
|
154
|
+
const parsed = parseBasicAuth(reqCtx.req.headers.authorization ?? '')
|
|
155
|
+
const { username, password } = parsed ?? {}
|
|
156
|
+
if (username !== 'admin') {
|
|
157
|
+
throw new AuthRequiredError()
|
|
158
|
+
}
|
|
159
|
+
const isAdmin = password === this.adminPassword
|
|
160
|
+
const isModerator = isAdmin || password === this.moderatorPassword
|
|
161
|
+
const isTriage = isModerator || password === this.triagePassword
|
|
162
|
+
if (!isTriage) {
|
|
163
|
+
throw new AuthRequiredError()
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
credentials: {
|
|
167
|
+
type: 'role',
|
|
168
|
+
isAdmin,
|
|
169
|
+
isModerator,
|
|
170
|
+
isTriage,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
nullCreds(): NullOutput {
|
|
176
|
+
return {
|
|
177
|
+
credentials: {
|
|
178
|
+
type: 'none',
|
|
179
|
+
iss: null,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const BEARER = 'Bearer '
|
|
186
|
+
const BASIC = 'Basic '
|
|
187
|
+
|
|
188
|
+
const isBearerToken = (req: express.Request): boolean => {
|
|
189
|
+
return req.headers.authorization?.startsWith(BEARER) ?? false
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const isBasicToken = (req: express.Request): boolean => {
|
|
193
|
+
return req.headers.authorization?.startsWith(BASIC) ?? false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const getJwtStrFromReq = (req: express.Request): string | null => {
|
|
197
|
+
const { authorization } = req.headers
|
|
198
|
+
if (!authorization?.startsWith(BEARER)) {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
return authorization.slice(BEARER.length).trim()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const parseBasicAuth = (
|
|
205
|
+
token: string,
|
|
206
|
+
): { username: string; password: string } | null => {
|
|
207
|
+
if (!token.startsWith(BASIC)) return null
|
|
208
|
+
const b64 = token.slice(BASIC.length)
|
|
209
|
+
let parsed: string[]
|
|
210
|
+
try {
|
|
211
|
+
parsed = ui8.toString(ui8.fromString(b64, 'base64pad'), 'utf8').split(':')
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
const [username, password] = parsed
|
|
216
|
+
if (!username || !password) return null
|
|
217
|
+
return { username, password }
|
|
218
|
+
}
|
package/src/config/config.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => {
|
|
|
13
13
|
publicUrl: env.publicUrl,
|
|
14
14
|
did: env.serverDid,
|
|
15
15
|
version: env.version,
|
|
16
|
+
devMode: env.devMode,
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
assert(env.dbPostgresUrl)
|
|
@@ -47,6 +48,12 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => {
|
|
|
47
48
|
plcUrl: env.didPlcUrl,
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
const accessCfg: OzoneConfig['access'] = {
|
|
52
|
+
admins: env.adminDids,
|
|
53
|
+
moderators: env.moderatorDids,
|
|
54
|
+
triage: env.triageDids,
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
return {
|
|
51
58
|
service: serviceCfg,
|
|
52
59
|
db: dbCfg,
|
|
@@ -54,6 +61,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => {
|
|
|
54
61
|
pds: pdsCfg,
|
|
55
62
|
cdn: cdnCfg,
|
|
56
63
|
identity: identityCfg,
|
|
64
|
+
access: accessCfg,
|
|
57
65
|
}
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -64,6 +72,7 @@ export type OzoneConfig = {
|
|
|
64
72
|
pds: PdsConfig | null
|
|
65
73
|
cdn: CdnConfig
|
|
66
74
|
identity: IdentityConfig
|
|
75
|
+
access: AccessConfig
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
export type ServiceConfig = {
|
|
@@ -71,6 +80,7 @@ export type ServiceConfig = {
|
|
|
71
80
|
publicUrl: string
|
|
72
81
|
did: string
|
|
73
82
|
version?: string
|
|
83
|
+
devMode?: boolean
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
export type DatabaseConfig = {
|
|
@@ -91,10 +101,16 @@ export type PdsConfig = {
|
|
|
91
101
|
did: string
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
export type CdnConfig = {
|
|
105
|
+
paths?: string[]
|
|
106
|
+
}
|
|
107
|
+
|
|
94
108
|
export type IdentityConfig = {
|
|
95
109
|
plcUrl: string
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
export type
|
|
99
|
-
|
|
112
|
+
export type AccessConfig = {
|
|
113
|
+
admins: string[]
|
|
114
|
+
moderators: string[]
|
|
115
|
+
triage: string[]
|
|
100
116
|
}
|
package/src/config/env.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { envInt, envList, envStr } from '@atproto/common'
|
|
1
|
+
import { envBool, envInt, envList, envStr } from '@atproto/common'
|
|
2
2
|
|
|
3
3
|
export const readEnv = (): OzoneEnvironment => {
|
|
4
4
|
return {
|
|
5
5
|
nodeEnv: envStr('NODE_ENV'),
|
|
6
|
+
devMode: envBool('OZONE_DEV_MODE'),
|
|
6
7
|
version: envStr('OZONE_VERSION'),
|
|
7
8
|
port: envInt('OZONE_PORT'),
|
|
8
9
|
publicUrl: envStr('OZONE_PUBLIC_URL'),
|
|
@@ -18,6 +19,9 @@ export const readEnv = (): OzoneEnvironment => {
|
|
|
18
19
|
dbPoolIdleTimeoutMs: envInt('OZONE_DB_POOL_IDLE_TIMEOUT_MS'),
|
|
19
20
|
didPlcUrl: envStr('OZONE_DID_PLC_URL'),
|
|
20
21
|
cdnPaths: envList('OZONE_CDN_PATHS'),
|
|
22
|
+
adminDids: envList('OZONE_ADMIN_DIDS'),
|
|
23
|
+
moderatorDids: envList('OZONE_MODERATOR_DIDS'),
|
|
24
|
+
triageDids: envList('OZONE_TRIAGE_DIDS'),
|
|
21
25
|
adminPassword: envStr('OZONE_ADMIN_PASSWORD'),
|
|
22
26
|
moderatorPassword: envStr('OZONE_MODERATOR_PASSWORD'),
|
|
23
27
|
triagePassword: envStr('OZONE_TRIAGE_PASSWORD'),
|
|
@@ -27,6 +31,7 @@ export const readEnv = (): OzoneEnvironment => {
|
|
|
27
31
|
|
|
28
32
|
export type OzoneEnvironment = {
|
|
29
33
|
nodeEnv?: string
|
|
34
|
+
devMode?: boolean
|
|
30
35
|
version?: string
|
|
31
36
|
port?: number
|
|
32
37
|
publicUrl?: string
|
|
@@ -42,6 +47,9 @@ export type OzoneEnvironment = {
|
|
|
42
47
|
dbPoolIdleTimeoutMs?: number
|
|
43
48
|
didPlcUrl?: string
|
|
44
49
|
cdnPaths?: string[]
|
|
50
|
+
adminDids: string[]
|
|
51
|
+
moderatorDids: string[]
|
|
52
|
+
triageDids: string[]
|
|
45
53
|
adminPassword?: string
|
|
46
54
|
moderatorPassword?: string
|
|
47
55
|
triagePassword?: string
|
package/src/context.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { createServiceAuthHeaders } from '@atproto/xrpc-server'
|
|
|
6
6
|
import { Database } from './db'
|
|
7
7
|
import { OzoneConfig, OzoneSecrets } from './config'
|
|
8
8
|
import { ModerationService, ModerationServiceCreator } from './mod-service'
|
|
9
|
-
import * as auth from './auth'
|
|
10
9
|
import { BackgroundQueue } from './background'
|
|
11
10
|
import assert from 'assert'
|
|
12
11
|
import { EventPusher } from './daemon'
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
CommunicationTemplateService,
|
|
16
15
|
CommunicationTemplateServiceCreator,
|
|
17
16
|
} from './communication-service/template'
|
|
17
|
+
import { AuthVerifier } from './auth-verifier'
|
|
18
18
|
import { ImageInvalidator } from './image-invalidator'
|
|
19
19
|
|
|
20
20
|
export type AppContextOptions = {
|
|
@@ -29,6 +29,7 @@ export type AppContextOptions = {
|
|
|
29
29
|
imgInvalidator?: ImageInvalidator
|
|
30
30
|
backgroundQueue: BackgroundQueue
|
|
31
31
|
sequencer: Sequencer
|
|
32
|
+
authVerifier: AuthVerifier
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export class AppContext {
|
|
@@ -54,12 +55,10 @@ export class AppContext {
|
|
|
54
55
|
|
|
55
56
|
const createAuthHeaders = (aud: string) =>
|
|
56
57
|
createServiceAuthHeaders({
|
|
57
|
-
iss: cfg.service.did
|
|
58
|
+
iss: `${cfg.service.did}#atproto_labeler`,
|
|
58
59
|
aud,
|
|
59
60
|
keypair: signingKey,
|
|
60
61
|
})
|
|
61
|
-
const appviewAuth = async () =>
|
|
62
|
-
cfg.appview.did ? createAuthHeaders(cfg.appview.did) : undefined
|
|
63
62
|
|
|
64
63
|
const backgroundQueue = new BackgroundQueue(db)
|
|
65
64
|
const eventPusher = new EventPusher(db, createAuthHeaders, {
|
|
@@ -67,11 +66,17 @@ export class AppContext {
|
|
|
67
66
|
pds: cfg.pds ?? undefined,
|
|
68
67
|
})
|
|
69
68
|
|
|
69
|
+
const idResolver = new IdResolver({
|
|
70
|
+
plcUrl: cfg.identity.plcUrl,
|
|
71
|
+
})
|
|
72
|
+
|
|
70
73
|
const modService = ModerationService.creator(
|
|
74
|
+
cfg,
|
|
71
75
|
backgroundQueue,
|
|
76
|
+
idResolver,
|
|
72
77
|
eventPusher,
|
|
73
78
|
appviewAgent,
|
|
74
|
-
|
|
79
|
+
createAuthHeaders,
|
|
75
80
|
cfg.service.did,
|
|
76
81
|
overrides?.imgInvalidator,
|
|
77
82
|
cfg.cdn.paths,
|
|
@@ -79,12 +84,18 @@ export class AppContext {
|
|
|
79
84
|
|
|
80
85
|
const communicationTemplateService = CommunicationTemplateService.creator()
|
|
81
86
|
|
|
82
|
-
const idResolver = new IdResolver({
|
|
83
|
-
plcUrl: cfg.identity.plcUrl,
|
|
84
|
-
})
|
|
85
|
-
|
|
86
87
|
const sequencer = new Sequencer(db)
|
|
87
88
|
|
|
89
|
+
const authVerifier = new AuthVerifier(idResolver, {
|
|
90
|
+
serviceDid: cfg.service.did,
|
|
91
|
+
admins: cfg.access.admins,
|
|
92
|
+
moderators: cfg.access.moderators,
|
|
93
|
+
triage: cfg.access.triage,
|
|
94
|
+
adminPassword: secrets.adminPassword,
|
|
95
|
+
moderatorPassword: secrets.moderatorPassword,
|
|
96
|
+
triagePassword: secrets.triagePassword,
|
|
97
|
+
})
|
|
98
|
+
|
|
88
99
|
return new AppContext(
|
|
89
100
|
{
|
|
90
101
|
db,
|
|
@@ -97,6 +108,7 @@ export class AppContext {
|
|
|
97
108
|
idResolver,
|
|
98
109
|
backgroundQueue,
|
|
99
110
|
sequencer,
|
|
111
|
+
authVerifier,
|
|
100
112
|
...(overrides ?? {}),
|
|
101
113
|
},
|
|
102
114
|
secrets,
|
|
@@ -155,38 +167,12 @@ export class AppContext {
|
|
|
155
167
|
return this.opts.sequencer
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
get authVerifier() {
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
get authVerifierAnyAudience() {
|
|
163
|
-
return auth.authVerifier(this.idResolver, { aud: null })
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
get authOptionalVerifierAnyAudience() {
|
|
167
|
-
return auth.authOptionalVerifier(this.idResolver, { aud: null })
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
get authOptionalVerifier() {
|
|
171
|
-
return auth.authOptionalVerifier(this.idResolver, {
|
|
172
|
-
aud: this.cfg.service.did,
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
get authOptionalAccessOrRoleVerifier() {
|
|
177
|
-
return auth.authOptionalAccessOrRoleVerifier(
|
|
178
|
-
this.idResolver,
|
|
179
|
-
this.secrets,
|
|
180
|
-
this.cfg.service.did,
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
get roleVerifier() {
|
|
185
|
-
return auth.roleVerifier(this.secrets)
|
|
170
|
+
get authVerifier(): AuthVerifier {
|
|
171
|
+
return this.opts.authVerifier
|
|
186
172
|
}
|
|
187
173
|
|
|
188
174
|
async serviceAuthHeaders(aud: string) {
|
|
189
|
-
const iss = this.cfg.service.did
|
|
175
|
+
const iss = `${this.cfg.service.did}#atproto_labeler`
|
|
190
176
|
return createServiceAuthHeaders({
|
|
191
177
|
iss,
|
|
192
178
|
aud,
|
package/src/daemon/context.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { EventPusher } from './event-pusher'
|
|
|
7
7
|
import { EventReverser } from './event-reverser'
|
|
8
8
|
import { ModerationService, ModerationServiceCreator } from '../mod-service'
|
|
9
9
|
import { BackgroundQueue } from '../background'
|
|
10
|
+
import { IdResolver } from '@atproto/identity'
|
|
10
11
|
|
|
11
12
|
export type DaemonContextOptions = {
|
|
12
13
|
db: Database
|
|
@@ -39,21 +40,26 @@ export class DaemonContext {
|
|
|
39
40
|
keypair: signingKey,
|
|
40
41
|
})
|
|
41
42
|
|
|
42
|
-
const appviewAuth = async () =>
|
|
43
|
-
cfg.appview.did ? createAuthHeaders(cfg.appview.did) : undefined
|
|
44
|
-
|
|
45
43
|
const eventPusher = new EventPusher(db, createAuthHeaders, {
|
|
46
44
|
appview: cfg.appview,
|
|
47
45
|
pds: cfg.pds ?? undefined,
|
|
48
46
|
})
|
|
47
|
+
|
|
49
48
|
const backgroundQueue = new BackgroundQueue(db)
|
|
49
|
+
const idResolver = new IdResolver({
|
|
50
|
+
plcUrl: cfg.identity.plcUrl,
|
|
51
|
+
})
|
|
52
|
+
|
|
50
53
|
const modService = ModerationService.creator(
|
|
54
|
+
cfg,
|
|
51
55
|
backgroundQueue,
|
|
56
|
+
idResolver,
|
|
52
57
|
eventPusher,
|
|
53
58
|
appviewAgent,
|
|
54
|
-
|
|
59
|
+
createAuthHeaders,
|
|
55
60
|
cfg.service.did,
|
|
56
61
|
)
|
|
62
|
+
|
|
57
63
|
const eventReverser = new EventReverser(db, modService)
|
|
58
64
|
|
|
59
65
|
return new DaemonContext({
|
|
@@ -205,7 +205,7 @@ export class EventPusher {
|
|
|
205
205
|
? { confirmedAt: new Date() }
|
|
206
206
|
: {
|
|
207
207
|
lastAttempted: new Date(),
|
|
208
|
-
attempts: evt.attempts ?? 0 + 1,
|
|
208
|
+
attempts: (evt.attempts ?? 0) + 1,
|
|
209
209
|
},
|
|
210
210
|
)
|
|
211
211
|
.where('subjectDid', '=', evt.subjectDid)
|
|
@@ -244,7 +244,7 @@ export class EventPusher {
|
|
|
244
244
|
? { confirmedAt: new Date() }
|
|
245
245
|
: {
|
|
246
246
|
lastAttempted: new Date(),
|
|
247
|
-
attempts: evt.attempts ?? 0 + 1,
|
|
247
|
+
attempts: (evt.attempts ?? 0) + 1,
|
|
248
248
|
},
|
|
249
249
|
)
|
|
250
250
|
.where('subjectUri', '=', evt.subjectUri)
|
|
@@ -284,7 +284,7 @@ export class EventPusher {
|
|
|
284
284
|
? { confirmedAt: new Date() }
|
|
285
285
|
: {
|
|
286
286
|
lastAttempted: new Date(),
|
|
287
|
-
attempts: evt.attempts ?? 0 + 1,
|
|
287
|
+
attempts: (evt.attempts ?? 0) + 1,
|
|
288
288
|
},
|
|
289
289
|
)
|
|
290
290
|
.where('subjectDid', '=', evt.subjectDid)
|
package/src/lexicon/lexicons.ts
CHANGED
|
@@ -897,6 +897,10 @@ export const schemaDict = {
|
|
|
897
897
|
type: 'string',
|
|
898
898
|
description: 'The subject line of the email sent to the user.',
|
|
899
899
|
},
|
|
900
|
+
content: {
|
|
901
|
+
type: 'string',
|
|
902
|
+
description: 'The content of the email sent to the user.',
|
|
903
|
+
},
|
|
900
904
|
comment: {
|
|
901
905
|
type: 'string',
|
|
902
906
|
description: 'Additional comment about the outgoing comm.',
|
|
@@ -5180,6 +5184,7 @@ export const schemaDict = {
|
|
|
5180
5184
|
type: 'boolean',
|
|
5181
5185
|
description:
|
|
5182
5186
|
'Hide replies in the feed if they are not by followed users.',
|
|
5187
|
+
default: true,
|
|
5183
5188
|
},
|
|
5184
5189
|
hideRepliesByLikeCount: {
|
|
5185
5190
|
type: 'integer',
|
|
@@ -197,7 +197,7 @@ export interface FeedViewPref {
|
|
|
197
197
|
/** Hide replies in the feed. */
|
|
198
198
|
hideReplies?: boolean
|
|
199
199
|
/** Hide replies in the feed if they are not by followed users. */
|
|
200
|
-
hideRepliesByUnfollowed
|
|
200
|
+
hideRepliesByUnfollowed: boolean
|
|
201
201
|
/** Hide replies in the feed if they do not have this number of likes. */
|
|
202
202
|
hideRepliesByLikeCount?: number
|
|
203
203
|
/** Hide reposts in the feed. */
|
|
@@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult {
|
|
|
704
704
|
export interface ModEventEmail {
|
|
705
705
|
/** The subject line of the email sent to the user. */
|
|
706
706
|
subjectLine: string
|
|
707
|
+
/** The content of the email sent to the user. */
|
|
708
|
+
content?: string
|
|
707
709
|
/** Additional comment about the outgoing comm. */
|
|
708
710
|
comment?: string
|
|
709
711
|
[k: string]: unknown
|
package/src/mod-service/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import net from 'node:net'
|
|
2
|
+
import { Insertable, sql } from 'kysely'
|
|
1
3
|
import { CID } from 'multiformats/cid'
|
|
2
4
|
import { AtUri, INVALID_HANDLE } from '@atproto/syntax'
|
|
3
5
|
import { InvalidRequestError } from '@atproto/xrpc-server'
|
|
4
6
|
import { addHoursToDate } from '@atproto/common'
|
|
7
|
+
import { IdResolver } from '@atproto/identity'
|
|
8
|
+
import AtpAgent from '@atproto/api'
|
|
5
9
|
import { Database } from '../db'
|
|
6
|
-
import {
|
|
10
|
+
import { AuthHeaders, ModerationViews } from './views'
|
|
7
11
|
import { Main as StrongRef } from '../lexicon/types/com/atproto/repo/strongRef'
|
|
8
12
|
import {
|
|
9
13
|
isModEventComment,
|
|
@@ -30,9 +34,7 @@ import {
|
|
|
30
34
|
} from './types'
|
|
31
35
|
import { ModerationEvent } from '../db/schema/moderation_event'
|
|
32
36
|
import { StatusKeyset, TimeIdKeyset, paginate } from '../db/pagination'
|
|
33
|
-
import AtpAgent from '@atproto/api'
|
|
34
37
|
import { Label } from '../lexicon/types/com/atproto/label/defs'
|
|
35
|
-
import { Insertable, sql } from 'kysely'
|
|
36
38
|
import {
|
|
37
39
|
ModSubject,
|
|
38
40
|
RecordSubject,
|
|
@@ -46,26 +48,31 @@ import { BackgroundQueue } from '../background'
|
|
|
46
48
|
import { EventPusher } from '../daemon'
|
|
47
49
|
import { ImageInvalidator } from '../image-invalidator'
|
|
48
50
|
import { httpLogger as log } from '../logger'
|
|
51
|
+
import { OzoneConfig } from '../config'
|
|
49
52
|
|
|
50
53
|
export type ModerationServiceCreator = (db: Database) => ModerationService
|
|
51
54
|
|
|
52
55
|
export class ModerationService {
|
|
53
56
|
constructor(
|
|
54
57
|
public db: Database,
|
|
58
|
+
public cfg: OzoneConfig,
|
|
55
59
|
public backgroundQueue: BackgroundQueue,
|
|
60
|
+
public idResolver: IdResolver,
|
|
56
61
|
public eventPusher: EventPusher,
|
|
57
62
|
public appviewAgent: AtpAgent,
|
|
58
|
-
private
|
|
63
|
+
private createAuthHeaders: (aud: string) => Promise<AuthHeaders>,
|
|
59
64
|
public serverDid: string,
|
|
60
65
|
public imgInvalidator?: ImageInvalidator,
|
|
61
66
|
public cdnPaths?: string[],
|
|
62
67
|
) {}
|
|
63
68
|
|
|
64
69
|
static creator(
|
|
70
|
+
cfg: OzoneConfig,
|
|
65
71
|
backgroundQueue: BackgroundQueue,
|
|
72
|
+
idResolver: IdResolver,
|
|
66
73
|
eventPusher: EventPusher,
|
|
67
74
|
appviewAgent: AtpAgent,
|
|
68
|
-
|
|
75
|
+
createAuthHeaders: (aud: string) => Promise<AuthHeaders>,
|
|
69
76
|
serverDid: string,
|
|
70
77
|
imgInvalidator?: ImageInvalidator,
|
|
71
78
|
cdnPaths?: string[],
|
|
@@ -73,17 +80,21 @@ export class ModerationService {
|
|
|
73
80
|
return (db: Database) =>
|
|
74
81
|
new ModerationService(
|
|
75
82
|
db,
|
|
83
|
+
cfg,
|
|
76
84
|
backgroundQueue,
|
|
85
|
+
idResolver,
|
|
77
86
|
eventPusher,
|
|
78
87
|
appviewAgent,
|
|
79
|
-
|
|
88
|
+
createAuthHeaders,
|
|
80
89
|
serverDid,
|
|
81
90
|
imgInvalidator,
|
|
82
91
|
cdnPaths,
|
|
83
92
|
)
|
|
84
93
|
}
|
|
85
94
|
|
|
86
|
-
views = new ModerationViews(this.db, this.appviewAgent,
|
|
95
|
+
views = new ModerationViews(this.db, this.appviewAgent, () =>
|
|
96
|
+
this.createAuthHeaders(this.cfg.appview.did),
|
|
97
|
+
)
|
|
87
98
|
|
|
88
99
|
async getEvent(id: number): Promise<ModerationEventRow | undefined> {
|
|
89
100
|
return await this.db.db
|
|
@@ -291,6 +302,9 @@ export class ModerationService {
|
|
|
291
302
|
|
|
292
303
|
if (isModEventEmail(event)) {
|
|
293
304
|
meta.subjectLine = event.subjectLine
|
|
305
|
+
if (event.content) {
|
|
306
|
+
meta.content = event.content
|
|
307
|
+
}
|
|
294
308
|
}
|
|
295
309
|
|
|
296
310
|
const subjectInfo = subject.info()
|
|
@@ -903,6 +917,49 @@ export class ModerationService {
|
|
|
903
917
|
)
|
|
904
918
|
.execute()
|
|
905
919
|
}
|
|
920
|
+
|
|
921
|
+
async sendEmail(opts: {
|
|
922
|
+
content: string
|
|
923
|
+
recipientDid: string
|
|
924
|
+
subject: string
|
|
925
|
+
}) {
|
|
926
|
+
const { subject, content, recipientDid } = opts
|
|
927
|
+
const { pds } = await this.idResolver.did.resolveAtprotoData(recipientDid)
|
|
928
|
+
const url = new URL(pds)
|
|
929
|
+
if (!this.cfg.service.devMode && !isSafeUrl(url)) {
|
|
930
|
+
throw new InvalidRequestError('Invalid pds service in DID doc')
|
|
931
|
+
}
|
|
932
|
+
const agent = new AtpAgent({ service: url })
|
|
933
|
+
const { data: serverInfo } =
|
|
934
|
+
await agent.api.com.atproto.server.describeServer()
|
|
935
|
+
if (serverInfo.did !== `did:web:${url.hostname}`) {
|
|
936
|
+
// @TODO do bidirectional check once implemented. in the meantime,
|
|
937
|
+
// matching did to hostname we're talking to is pretty good.
|
|
938
|
+
throw new InvalidRequestError('Invalid pds service in DID doc')
|
|
939
|
+
}
|
|
940
|
+
const { data: delivery } = await agent.api.com.atproto.admin.sendEmail(
|
|
941
|
+
{
|
|
942
|
+
subject,
|
|
943
|
+
content,
|
|
944
|
+
recipientDid,
|
|
945
|
+
senderDid: this.cfg.service.did,
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
encoding: 'application/json',
|
|
949
|
+
...(await this.createAuthHeaders(serverInfo.did)),
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
if (!delivery.sent) {
|
|
953
|
+
throw new InvalidRequestError('Email was accepted but not sent')
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const isSafeUrl = (url: URL) => {
|
|
959
|
+
if (url.protocol !== 'https:') return false
|
|
960
|
+
if (!url.hostname || url.hostname === 'localhost') return false
|
|
961
|
+
if (net.isIP(url.hostname) !== 0) return false
|
|
962
|
+
return true
|
|
906
963
|
}
|
|
907
964
|
|
|
908
965
|
const TAKEDOWNS = ['pds_takedown' as const, 'appview_takedown' as const]
|