@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/api/proxied.d.ts +3 -0
  3. package/dist/auth-verifier.d.ts +72 -0
  4. package/dist/config/config.d.ts +9 -2
  5. package/dist/config/env.d.ts +4 -0
  6. package/dist/context.d.ts +3 -90
  7. package/dist/db/index.js.map +2 -2
  8. package/dist/index.js +29669 -29467
  9. package/dist/index.js.map +3 -3
  10. package/dist/lexicon/lexicons.d.ts +5 -0
  11. package/dist/lexicon/types/app/bsky/actor/defs.d.ts +1 -1
  12. package/dist/lexicon/types/com/atproto/admin/defs.d.ts +1 -0
  13. package/dist/mod-service/index.d.ts +16 -7
  14. package/dist/mod-service/views.d.ts +3 -3
  15. package/package.json +5 -5
  16. package/src/api/admin/createCommunicationTemplate.ts +2 -2
  17. package/src/api/admin/deleteCommunicationTemplate.ts +2 -2
  18. package/src/api/admin/emitModerationEvent.ts +23 -4
  19. package/src/api/admin/getModerationEvent.ts +1 -1
  20. package/src/api/admin/getRecord.ts +2 -2
  21. package/src/api/admin/getRepo.ts +2 -2
  22. package/src/api/admin/listCommunicationTemplates.ts +2 -2
  23. package/src/api/admin/queryModerationEvents.ts +1 -1
  24. package/src/api/admin/queryModerationStatuses.ts +1 -1
  25. package/src/api/admin/searchRepos.ts +1 -1
  26. package/src/api/admin/updateCommunicationTemplate.ts +2 -2
  27. package/src/api/index.ts +2 -0
  28. package/src/api/moderation/createReport.ts +2 -2
  29. package/src/api/proxied.ts +116 -0
  30. package/src/api/temp/fetchLabels.ts +2 -2
  31. package/src/api/well-known.ts +3 -3
  32. package/src/auth-verifier.ts +218 -0
  33. package/src/config/config.ts +18 -2
  34. package/src/config/env.ts +9 -1
  35. package/src/context.ts +24 -38
  36. package/src/daemon/context.ts +10 -4
  37. package/src/daemon/event-pusher.ts +3 -3
  38. package/src/lexicon/lexicons.ts +5 -0
  39. package/src/lexicon/types/app/bsky/actor/defs.ts +1 -1
  40. package/src/lexicon/types/com/atproto/admin/defs.ts +2 -0
  41. package/src/mod-service/index.ts +64 -7
  42. package/src/mod-service/views.ts +22 -18
  43. package/tests/moderation-events.test.ts +49 -0
  44. package/dist/auth.d.ts +0 -81
  45. 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
+ }
@@ -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 CdnConfig = {
99
- paths?: string[]
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
- appviewAuth,
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 auth.authVerifier(this.idResolver, { aud: this.cfg.service.did })
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,
@@ -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
- appviewAuth,
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)
@@ -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?: boolean
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
@@ -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 { AppviewAuth, ModerationViews } from './views'
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 appviewAuth: AppviewAuth,
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
- appviewAuth: AppviewAuth,
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
- appviewAuth,
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, this.appviewAuth)
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]