Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ozone delegates email sending to actor's pds #2272

Merged
merged 3 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lexicons/com/atproto/admin/defs.json
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,10 @@
"type": "string",
"description": "The subject line of the email sent to the user."
},
"content": {
"type": "string",
"description": "The content of the email sent to the user."
},
"comment": {
"type": "string",
"description": "Additional comment about the outgoing comm."
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,10 @@ export const schemaDict = {
type: 'string',
description: 'The subject line of the email sent to the user.',
},
content: {
type: 'string',
description: 'The content of the email sent to the user.',
},
comment: {
type: 'string',
description: 'Additional comment about the outgoing comm.',
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/client/types/com/atproto/admin/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult {
export interface ModEventEmail {
/** The subject line of the email sent to the user. */
subjectLine: string
/** The content of the email sent to the user. */
content?: string
/** Additional comment about the outgoing comm. */
comment?: string
[k: string]: unknown
Expand Down
4 changes: 4 additions & 0 deletions packages/bsky/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,10 @@ export const schemaDict = {
type: 'string',
description: 'The subject line of the email sent to the user.',
},
content: {
type: 'string',
description: 'The content of the email sent to the user.',
},
comment: {
type: 'string',
description: 'Additional comment about the outgoing comm.',
Expand Down
2 changes: 2 additions & 0 deletions packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult {
export interface ModEventEmail {
/** The subject line of the email sent to the user. */
subjectLine: string
/** The content of the email sent to the user. */
content?: string
/** Additional comment about the outgoing comm. */
comment?: string
[k: string]: unknown
Expand Down
1 change: 1 addition & 0 deletions packages/dev-env/src/ozone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class TestOzone {
const port = config.port || (await getPort())
const url = `http://localhost:${port}`
const env: ozone.OzoneEnvironment = {
devMode: true,
version: '0.0.0',
port,
didPlcUrl: config.plcUrl,
Expand Down
19 changes: 19 additions & 0 deletions packages/ozone/src/api/admin/emitModerationEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../lexicon'
import AppContext from '../../context'
import {
isModEventEmail,
isModEventLabel,
isModEventReverseTakedown,
isModEventTakedown,
} from '../../lexicon/types/com/atproto/admin/defs'
import { subjectFromInput } from '../../mod-service/subject'
import { ModerationLangService } from '../../mod-service/lang'
import { retryHttp } from '../../util'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.emitModerationEvent({
Expand Down Expand Up @@ -75,6 +77,23 @@ export default function (server: Server, ctx: AppContext) {
}
}

if (isModEventEmail(event) && event.content) {
// sending email prior to logging the event to avoid a long transaction below
if (!subject.isRepo()) {
throw new InvalidRequestError(
'Email can only be sent to a repo subject',
)
}
const { content, subjectLine } = event
await retryHttp(() =>
ctx.modService(db).sendEmail({
subject: subjectLine,
content,
recipientDid: subject.did,
}),
)
}

const moderationEvent = await db.transaction(async (dbTxn) => {
const moderationTxn = ctx.modService(dbTxn)

Expand Down
2 changes: 2 additions & 0 deletions packages/ozone/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => {
publicUrl: env.publicUrl,
did: env.serverDid,
version: env.version,
devMode: env.devMode,
}

assert(env.dbPostgresUrl)
Expand Down Expand Up @@ -71,6 +72,7 @@ export type ServiceConfig = {
publicUrl: string
did: string
version?: string
devMode?: boolean
}

export type DatabaseConfig = {
Expand Down
4 changes: 3 additions & 1 deletion packages/ozone/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { envInt, envList, envStr } from '@atproto/common'
import { envBool, envInt, envList, envStr } from '@atproto/common'

export const readEnv = (): OzoneEnvironment => {
return {
nodeEnv: envStr('NODE_ENV'),
devMode: envBool('OZONE_DEV_MODE'),
version: envStr('OZONE_VERSION'),
port: envInt('OZONE_PORT'),
publicUrl: envStr('OZONE_PUBLIC_URL'),
Expand All @@ -27,6 +28,7 @@ export const readEnv = (): OzoneEnvironment => {

export type OzoneEnvironment = {
nodeEnv?: string
devMode?: boolean
version?: string
port?: number
publicUrl?: string
Expand Down
14 changes: 7 additions & 7 deletions packages/ozone/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,31 +58,31 @@ export class AppContext {
aud,
keypair: signingKey,
})
const appviewAuth = async () =>
cfg.appview.did ? createAuthHeaders(cfg.appview.did) : undefined

const backgroundQueue = new BackgroundQueue(db)
const eventPusher = new EventPusher(db, createAuthHeaders, {
appview: cfg.appview,
pds: cfg.pds ?? undefined,
})

const idResolver = new IdResolver({
plcUrl: cfg.identity.plcUrl,
})

const modService = ModerationService.creator(
cfg,
backgroundQueue,
idResolver,
eventPusher,
appviewAgent,
appviewAuth,
createAuthHeaders,
cfg.service.did,
overrides?.imgInvalidator,
cfg.cdn.paths,
)

const communicationTemplateService = CommunicationTemplateService.creator()

const idResolver = new IdResolver({
plcUrl: cfg.identity.plcUrl,
})

const sequencer = new Sequencer(db)

return new AppContext(
Expand Down
14 changes: 10 additions & 4 deletions packages/ozone/src/daemon/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventPusher } from './event-pusher'
import { EventReverser } from './event-reverser'
import { ModerationService, ModerationServiceCreator } from '../mod-service'
import { BackgroundQueue } from '../background'
import { IdResolver } from '@atproto/identity'

export type DaemonContextOptions = {
db: Database
Expand Down Expand Up @@ -39,21 +40,26 @@ export class DaemonContext {
keypair: signingKey,
})

const appviewAuth = async () =>
cfg.appview.did ? createAuthHeaders(cfg.appview.did) : undefined

const eventPusher = new EventPusher(db, createAuthHeaders, {
appview: cfg.appview,
pds: cfg.pds ?? undefined,
})

const backgroundQueue = new BackgroundQueue(db)
const idResolver = new IdResolver({
plcUrl: cfg.identity.plcUrl,
})

const modService = ModerationService.creator(
cfg,
backgroundQueue,
idResolver,
eventPusher,
appviewAgent,
appviewAuth,
createAuthHeaders,
cfg.service.did,
)

const eventReverser = new EventReverser(db, modService)

return new DaemonContext({
Expand Down
4 changes: 4 additions & 0 deletions packages/ozone/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,10 @@ export const schemaDict = {
type: 'string',
description: 'The subject line of the email sent to the user.',
},
content: {
type: 'string',
description: 'The content of the email sent to the user.',
},
comment: {
type: 'string',
description: 'Additional comment about the outgoing comm.',
Expand Down
2 changes: 2 additions & 0 deletions packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult {
export interface ModEventEmail {
/** The subject line of the email sent to the user. */
subjectLine: string
/** The content of the email sent to the user. */
content?: string
/** Additional comment about the outgoing comm. */
comment?: string
[k: string]: unknown
Expand Down
71 changes: 64 additions & 7 deletions packages/ozone/src/mod-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import net from 'node:net'
import { Insertable, sql } from 'kysely'
import { CID } from 'multiformats/cid'
import { AtUri, INVALID_HANDLE } from '@atproto/syntax'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { addHoursToDate } from '@atproto/common'
import { IdResolver } from '@atproto/identity'
import AtpAgent from '@atproto/api'
import { Database } from '../db'
import { AppviewAuth, ModerationViews } from './views'
import { AuthHeaders, ModerationViews } from './views'
import { Main as StrongRef } from '../lexicon/types/com/atproto/repo/strongRef'
import {
isModEventComment,
Expand All @@ -30,9 +34,7 @@ import {
} from './types'
import { ModerationEvent } from '../db/schema/moderation_event'
import { StatusKeyset, TimeIdKeyset, paginate } from '../db/pagination'
import AtpAgent from '@atproto/api'
import { Label } from '../lexicon/types/com/atproto/label/defs'
import { Insertable, sql } from 'kysely'
import {
ModSubject,
RecordSubject,
Expand All @@ -46,44 +48,53 @@ import { BackgroundQueue } from '../background'
import { EventPusher } from '../daemon'
import { ImageInvalidator } from '../image-invalidator'
import { httpLogger as log } from '../logger'
import { OzoneConfig } from '../config'

export type ModerationServiceCreator = (db: Database) => ModerationService

export class ModerationService {
constructor(
public db: Database,
public cfg: OzoneConfig,
public backgroundQueue: BackgroundQueue,
public idResolver: IdResolver,
public eventPusher: EventPusher,
public appviewAgent: AtpAgent,
private appviewAuth: AppviewAuth,
private createAuthHeaders: (aud: string) => Promise<AuthHeaders>,
public serverDid: string,
public imgInvalidator?: ImageInvalidator,
public cdnPaths?: string[],
) {}

static creator(
cfg: OzoneConfig,
backgroundQueue: BackgroundQueue,
idResolver: IdResolver,
eventPusher: EventPusher,
appviewAgent: AtpAgent,
appviewAuth: AppviewAuth,
createAuthHeaders: (aud: string) => Promise<AuthHeaders>,
serverDid: string,
imgInvalidator?: ImageInvalidator,
cdnPaths?: string[],
) {
return (db: Database) =>
new ModerationService(
db,
cfg,
backgroundQueue,
idResolver,
eventPusher,
appviewAgent,
appviewAuth,
createAuthHeaders,
serverDid,
imgInvalidator,
cdnPaths,
)
}

views = new ModerationViews(this.db, this.appviewAgent, this.appviewAuth)
views = new ModerationViews(this.db, this.appviewAgent, () =>
this.createAuthHeaders(this.cfg.appview.did),
)

async getEvent(id: number): Promise<ModerationEventRow | undefined> {
return await this.db.db
Expand Down Expand Up @@ -291,6 +302,9 @@ export class ModerationService {

if (isModEventEmail(event)) {
meta.subjectLine = event.subjectLine
if (event.content) {
meta.content = event.content
}
}

const subjectInfo = subject.info()
Expand Down Expand Up @@ -903,6 +917,49 @@ export class ModerationService {
)
.execute()
}

async sendEmail(opts: {
content: string
recipientDid: string
subject: string
}) {
const { subject, content, recipientDid } = opts
const { pds } = await this.idResolver.did.resolveAtprotoData(recipientDid)
const url = new URL(pds)
if (!this.cfg.service.devMode && !isSafeUrl(url)) {
throw new InvalidRequestError('Invalid pds service in DID doc')
}
const agent = new AtpAgent({ service: url })
const { data: serverInfo } =
await agent.api.com.atproto.server.describeServer()
if (serverInfo.did !== `did:web:${url.hostname}`) {
// @TODO do bidirectional check once implemented. in the meantime,
// matching did to hostname we're talking to is pretty good.
throw new InvalidRequestError('Invalid pds service in DID doc')
}
const { data: delivery } = await agent.api.com.atproto.admin.sendEmail(
{
subject,
content,
recipientDid,
senderDid: this.cfg.service.did,
},
{
encoding: 'application/json',
...(await this.createAuthHeaders(serverInfo.did)),
},
)
if (!delivery.sent) {
throw new InvalidRequestError('Email was accepted but not sent')
}
}
}

const isSafeUrl = (url: URL) => {
if (url.protocol !== 'https:') return false
if (!url.hostname || url.hostname === 'localhost') return false
if (net.isIP(url.hostname) === 0) return false
return true
}

const TAKEDOWNS = ['pds_takedown' as const, 'appview_takedown' as const]
Expand Down
Loading
Loading