diff --git a/.changeset/cuddly-adults-beg.md b/.changeset/cuddly-adults-beg.md deleted file mode 100644 index b47c8101273..00000000000 --- a/.changeset/cuddly-adults-beg.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/syntax': minor ---- - -allow colon character in record-key syntax diff --git a/.changeset/lovely-dogs-run.md b/.changeset/lovely-dogs-run.md deleted file mode 100644 index b2ac95215b6..00000000000 --- a/.changeset/lovely-dogs-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/api': patch ---- - -Fix mute word upsert logic by ensuring we're comparing sanitized word values diff --git a/.github/workflows/build-and-push-ozone-aws.yaml b/.github/workflows/build-and-push-ozone-aws.yaml index 53f95c5b731..b934d192b6f 100644 --- a/.github/workflows/build-and-push-ozone-aws.yaml +++ b/.github/workflows/build-and-push-ozone-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - ozone-cdn-invalidation env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index c73700474fe..1dc4944417d 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -594,6 +594,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." diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index 58732dbd074..a5bac9a4eaa 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,42 @@ # @atproto/api +## 0.10.4 + +### Patch Changes + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Export regex from rich text detection + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Disallow rare unicode whitespace characters from tags + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Allow tags to lead with numbers + +## 0.10.3 + +### Patch Changes + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Fix double sanitization bug when editing muted words. + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - More sanitization of muted words, including newlines and leading/trailing whitespace + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Add `sanitizeMutedWordValue` util + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Handle hash emoji in mute words + +## 0.10.2 + +### Patch Changes + +- [#2245](https://github.com/bluesky-social/atproto/pull/2245) [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410) Thanks [@mary-ext](https://github.com/mary-ext)! - Prevent hashtag emoji from being parsed as a tag + +- [#2218](https://github.com/bluesky-social/atproto/pull/2218) [`43531905c`](https://github.com/bluesky-social/atproto/commit/43531905ce1aec6d36d9be5943782811ecca6e6d) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Fix mute word upsert logic by ensuring we're comparing sanitized word values + +- [#2245](https://github.com/bluesky-social/atproto/pull/2245) [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410) Thanks [@mary-ext](https://github.com/mary-ext)! - Properly calculate length of tag + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4)]: + - @atproto/syntax@0.2.0 + - @atproto/lexicon@0.3.2 + - @atproto/xrpc@0.4.2 + ## 0.10.1 ### Patch Changes diff --git a/packages/api/package.json b/packages/api/package.json index ab76c7b4249..dbb18da1786 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/api", - "version": "0.10.1", + "version": "0.10.4", "license": "MIT", "description": "Client library for atproto and Bluesky", "keywords": [ diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 305348bea0b..ae504f90b8d 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -13,6 +13,7 @@ import { BskyThreadViewPreference, BskyInterestsPreference, } from './types' +import { sanitizeMutedWordValue } from './util' const FEED_VIEW_PREF_DEFAULTS = { hideReplies: false, @@ -565,16 +566,108 @@ export class BskyAgent extends AtpAgent { }) } - async upsertMutedWords(mutedWords: AppBskyActorDefs.MutedWord[]) { - await updateMutedWords(this, mutedWords, 'upsert') + async upsertMutedWords(newMutedWords: AppBskyActorDefs.MutedWord[]) { + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + let mutedWordsPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isMutedWordsPref(pref) && + AppBskyActorDefs.validateMutedWordsPref(pref).success, + ) + + if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) { + for (const updatedWord of newMutedWords) { + let foundMatch = false + const sanitizedUpdatedValue = sanitizeMutedWordValue( + updatedWord.value, + ) + + // was trimmed down to an empty string e.g. single `#` + if (!sanitizedUpdatedValue) continue + + for (const existingItem of mutedWordsPref.items) { + if (existingItem.value === sanitizedUpdatedValue) { + existingItem.targets = Array.from( + new Set([...existingItem.targets, ...updatedWord.targets]), + ) + foundMatch = true + break + } + } + + if (!foundMatch) { + mutedWordsPref.items.push({ + ...updatedWord, + value: sanitizedUpdatedValue, + }) + } + } + } else { + // if the pref doesn't exist, create it + mutedWordsPref = { + items: newMutedWords.map((w) => ({ + ...w, + value: sanitizeMutedWordValue(w.value), + })), + } + } + + return prefs + .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) + .concat([ + { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' }, + ]) + }) } async updateMutedWord(mutedWord: AppBskyActorDefs.MutedWord) { - await updateMutedWords(this, [mutedWord], 'update') + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + let mutedWordsPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isMutedWordsPref(pref) && + AppBskyActorDefs.validateMutedWordsPref(pref).success, + ) + + if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) { + for (const existingItem of mutedWordsPref.items) { + if (existingItem.value === mutedWord.value) { + existingItem.targets = mutedWord.targets + break + } + } + } + + return prefs + .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) + .concat([ + { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' }, + ]) + }) } async removeMutedWord(mutedWord: AppBskyActorDefs.MutedWord) { - await updateMutedWords(this, [mutedWord], 'remove') + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + let mutedWordsPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isMutedWordsPref(pref) && + AppBskyActorDefs.validateMutedWordsPref(pref).success, + ) + + if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) { + for (let i = 0; i < mutedWordsPref.items.length; i++) { + const existing = mutedWordsPref.items[i] + if (existing.value === mutedWord.value) { + mutedWordsPref.items.splice(i, 1) + break + } + } + } + + return prefs + .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) + .concat([ + { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' }, + ]) + }) } async hidePost(postUri: string) { @@ -646,76 +739,6 @@ async function updateFeedPreferences( return res } -/** - * A helper specifically for updating muted words preferences - */ -async function updateMutedWords( - agent: BskyAgent, - mutedWords: AppBskyActorDefs.MutedWord[], - action: 'upsert' | 'update' | 'remove', -) { - const sanitizeMutedWord = (word: AppBskyActorDefs.MutedWord) => ({ - value: word.value.replace(/^#/, ''), - targets: word.targets, - }) - - await updatePreferences(agent, (prefs: AppBskyActorDefs.Preferences) => { - let mutedWordsPref = prefs.findLast( - (pref) => - AppBskyActorDefs.isMutedWordsPref(pref) && - AppBskyActorDefs.validateMutedWordsPref(pref).success, - ) - - if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) { - if (action === 'upsert' || action === 'update') { - for (const word of mutedWords) { - let foundMatch = false - - for (const existingItem of mutedWordsPref.items) { - if (existingItem.value === sanitizeMutedWord(word).value) { - existingItem.targets = - action === 'upsert' - ? Array.from( - new Set([...existingItem.targets, ...word.targets]), - ) - : word.targets - foundMatch = true - break - } - } - - if (action === 'upsert' && !foundMatch) { - mutedWordsPref.items.push(sanitizeMutedWord(word)) - } - } - } else if (action === 'remove') { - for (const word of mutedWords) { - for (let i = 0; i < mutedWordsPref.items.length; i++) { - const existing = mutedWordsPref.items[i] - if (existing.value === sanitizeMutedWord(word).value) { - mutedWordsPref.items.splice(i, 1) - break - } - } - } - } - } else { - // if the pref doesn't exist, create it - if (action === 'upsert') { - mutedWordsPref = { - items: mutedWords.map(sanitizeMutedWord), - } - } - } - - return prefs - .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) - .concat([ - { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' }, - ]) - }) -} - async function updateHiddenPost( agent: BskyAgent, postUri: string, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index a675a0b0201..7e87cbb763e 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -903,6 +903,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.', diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index 082282505b6..af94ecceaff 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -707,6 +707,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 diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 958e8930603..87cf1ccf01a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,11 +8,13 @@ export { } from '@atproto/lexicon' export { parseLanguage } from '@atproto/common-web' export * from './types' +export * from './util' export * from './client' export * from './agent' export * from './rich-text/rich-text' export * from './rich-text/sanitization' export * from './rich-text/unicode' +export * from './rich-text/util' export * from './moderation' export * from './moderation/types' export { LABELS } from './moderation/const/labels' diff --git a/packages/api/src/rich-text/detection.ts b/packages/api/src/rich-text/detection.ts index 25edcd9e57b..22c5db1b087 100644 --- a/packages/api/src/rich-text/detection.ts +++ b/packages/api/src/rich-text/detection.ts @@ -1,6 +1,12 @@ import TLDs from 'tlds' import { AppBskyRichtextFacet } from '../client' import { UnicodeString } from './unicode' +import { + URL_REGEX, + MENTION_REGEX, + TAG_REGEX, + TRAILING_PUNCTUATION_REGEX, +} from './util' export type Facet = AppBskyRichtextFacet.Main @@ -9,7 +15,7 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { const facets: Facet[] = [] { // mentions - const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g + const re = MENTION_REGEX while ((match = re.exec(text.utf16))) { if (!isValidDomain(match[3]) && !match[3].endsWith('.test')) { continue // probably not a handle @@ -33,8 +39,7 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { } { // links - const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + const re = URL_REGEX while ((match = re.exec(text.utf16))) { let uri = match[2] if (!uri.startsWith('http')) { @@ -70,27 +75,28 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { } } { - const re = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const re = TAG_REGEX while ((match = re.exec(text.utf16))) { - let [tag] = match - const hasLeadingSpace = /^\s/.test(tag) + let [, leading, tag] = match - tag = tag.trim().replace(/\p{P}+$/gu, '') // strip ending punctuation + if (!tag) continue - // inclusive of #, max of 64 chars - if (tag.length > 66) continue + // strip ending punctuation and any spaces + tag = tag.trim().replace(TRAILING_PUNCTUATION_REGEX, '') - const index = match.index + (hasLeadingSpace ? 1 : 0) + if (tag.length === 0 || tag.length > 64) continue + + const index = match.index + leading.length facets.push({ index: { byteStart: text.utf16IndexToUtf8Index(index), - byteEnd: text.utf16IndexToUtf8Index(index + tag.length), // inclusive of last char + byteEnd: text.utf16IndexToUtf8Index(index + 1 + tag.length), }, features: [ { $type: 'app.bsky.richtext.facet#tag', - tag: tag.replace(/^#/, ''), + tag: tag, }, ], }) diff --git a/packages/api/src/rich-text/util.ts b/packages/api/src/rich-text/util.ts new file mode 100644 index 00000000000..ab50c66212d --- /dev/null +++ b/packages/api/src/rich-text/util.ts @@ -0,0 +1,11 @@ +export const MENTION_REGEX = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g +export const URL_REGEX = + /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim +export const TRAILING_PUNCTUATION_REGEX = /\p{P}+$/gu + +/** + * `\ufe0f` emoji modifier + * `\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2` zero-width spaces (likely incomplete) + */ +export const TAG_REGEX = + /(^|\s)[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)?/gu diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts new file mode 100644 index 00000000000..58d60a5e48b --- /dev/null +++ b/packages/api/src/util.ts @@ -0,0 +1,6 @@ +export function sanitizeMutedWordValue(value: string) { + return value + .trim() + .replace(/^#(?!\ufe0f)/, '') + .replace(/[\r\n\u00AD\u2060\u200D\u200C\u200B]+/, '') +} diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index db8ee0a1567..273308625a0 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -1202,13 +1202,18 @@ describe('agent', () => { await agent.upsertMutedWords([ { value: 'hashtag', targets: ['content'] }, ]) + // is sanitized to `hashtag` await agent.upsertMutedWords([{ value: '#hashtag', targets: ['tag'] }]) + const { mutedWords } = await agent.getPreferences() + expect(mutedWords.find((m) => m.value === '#hashtag')).toBeFalsy() + // merged with existing expect(mutedWords.find((m) => m.value === 'hashtag')).toStrictEqual({ value: 'hashtag', targets: ['content', 'tag'], }) + // only one added expect(mutedWords.filter((m) => m.value === 'hashtag').length).toBe(1) }) @@ -1237,15 +1242,21 @@ describe('agent', () => { expect(mutedWords.find((m) => m.value === 'no_exist')).toBeFalsy() }) - it('updateMutedWord with #', async () => { + it('updateMutedWord with #, does not update', async () => { + await agent.upsertMutedWords([ + { + value: '#just_a_tag', + targets: ['tag'], + }, + ]) await agent.updateMutedWord({ - value: 'hashtag', + value: '#just_a_tag', targets: ['tag', 'content'], }) const { mutedWords } = await agent.getPreferences() - expect(mutedWords.find((m) => m.value === 'hashtag')).toStrictEqual({ - value: 'hashtag', - targets: ['tag', 'content'], + expect(mutedWords.find((m) => m.value === 'just_a_tag')).toStrictEqual({ + value: 'just_a_tag', + targets: ['tag'], }) }) @@ -1262,11 +1273,124 @@ describe('agent', () => { expect(mutedWords.find((m) => m.value === 'tag_then_none')).toBeFalsy() }) - it('removeMutedWord with #', async () => { + it('removeMutedWord with #, no match, no removal', async () => { await agent.removeMutedWord({ value: '#hashtag', targets: [] }) const { mutedWords } = await agent.getPreferences() - expect(mutedWords.find((m) => m.value === 'hashtag')).toBeFalsy() + // was inserted with #hashtag, but we don't sanitize on remove + expect(mutedWords.find((m) => m.value === 'hashtag')).toBeTruthy() + }) + + it('single-hash #', async () => { + const prev = await agent.getPreferences() + const length = prev.mutedWords.length + await agent.upsertMutedWords([{ value: '#', targets: [] }]) + const end = await agent.getPreferences() + + // sanitized to empty string, not inserted + expect(end.mutedWords.length).toEqual(length) + }) + + it('multi-hash ##', async () => { + await agent.upsertMutedWords([{ value: '##', targets: [] }]) + const { mutedWords } = await agent.getPreferences() + + expect(mutedWords.find((m) => m.value === '#')).toBeTruthy() + }) + + it('multi-hash ##hashtag', async () => { + await agent.upsertMutedWords([{ value: '##hashtag', targets: [] }]) + const a = await agent.getPreferences() + + expect(a.mutedWords.find((w) => w.value === '#hashtag')).toBeTruthy() + + await agent.removeMutedWord({ value: '#hashtag', targets: [] }) + const b = await agent.getPreferences() + + expect(b.mutedWords.find((w) => w.value === '#hashtag')).toBeFalsy() + }) + + it('hash emoji #️⃣', async () => { + await agent.upsertMutedWords([{ value: '#️⃣', targets: [] }]) + const { mutedWords } = await agent.getPreferences() + + expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy() + + await agent.removeMutedWord({ value: '#️⃣', targets: [] }) + const end = await agent.getPreferences() + + expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy() + }) + + it('hash emoji ##️⃣', async () => { + await agent.upsertMutedWords([{ value: '##️⃣', targets: [] }]) + const { mutedWords } = await agent.getPreferences() + + expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy() + + await agent.removeMutedWord({ value: '#️⃣', targets: [] }) + const end = await agent.getPreferences() + + expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy() + }) + + it('hash emoji ###️⃣', async () => { + await agent.upsertMutedWords([{ value: '###️⃣', targets: [] }]) + const { mutedWords } = await agent.getPreferences() + + expect(mutedWords.find((m) => m.value === '##️⃣')).toBeTruthy() + + await agent.removeMutedWord({ value: '##️⃣', targets: [] }) + const end = await agent.getPreferences() + + expect(end.mutedWords.find((m) => m.value === '##️⃣')).toBeFalsy() + }) + + describe(`invalid characters`, () => { + it('zero width space', async () => { + const prev = await agent.getPreferences() + const length = prev.mutedWords.length + await agent.upsertMutedWords([{ value: '#​', targets: [] }]) + const { mutedWords } = await agent.getPreferences() + + expect(mutedWords.length).toEqual(length) + }) + + it('newline', async () => { + await agent.upsertMutedWords([ + { value: 'test value\n with newline', targets: [] }, + ]) + const { mutedWords } = await agent.getPreferences() + + expect( + mutedWords.find((m) => m.value === 'test value with newline'), + ).toBeTruthy() + }) + + it('newline(s)', async () => { + await agent.upsertMutedWords([ + { value: 'test value\n\r with newline', targets: [] }, + ]) + const { mutedWords } = await agent.getPreferences() + + expect( + mutedWords.find((m) => m.value === 'test value with newline'), + ).toBeTruthy() + }) + + it('empty space', async () => { + await agent.upsertMutedWords([{ value: ' ', targets: [] }]) + const { mutedWords } = await agent.getPreferences() + + expect(mutedWords.find((m) => m.value === ' ')).toBeFalsy() + }) + + it('leading/trailing space', async () => { + await agent.upsertMutedWords([{ value: ' trim ', targets: [] }]) + const { mutedWords } = await agent.getPreferences() + + expect(mutedWords.find((m) => m.value === 'trim')).toBeTruthy() + }) }) }) diff --git a/packages/api/tests/rich-text-detection.test.ts b/packages/api/tests/rich-text-detection.test.ts index 9498005076c..084b5440a48 100644 --- a/packages/api/tests/rich-text-detection.test.ts +++ b/packages/api/tests/rich-text-detection.test.ts @@ -218,7 +218,7 @@ describe('detectFacets', () => { } }) - it('correctly detects tags inline', async () => { + describe('correctly detects tags inline', () => { const inputs: [ string, string[], @@ -234,31 +234,40 @@ describe('detectFacets', () => { ], ], ['#1', [], []], + ['#1a', ['1a'], [{ byteStart: 0, byteEnd: 3 }]], ['#tag', ['tag'], [{ byteStart: 0, byteEnd: 4 }]], ['body #tag', ['tag'], [{ byteStart: 5, byteEnd: 9 }]], ['#tag body', ['tag'], [{ byteStart: 0, byteEnd: 4 }]], ['body #tag body', ['tag'], [{ byteStart: 5, byteEnd: 9 }]], ['body #1', [], []], + ['body #1a', ['1a'], [{ byteStart: 5, byteEnd: 8 }]], ['body #a1', ['a1'], [{ byteStart: 5, byteEnd: 8 }]], ['#', [], []], + ['#?', [], []], ['text #', [], []], ['text # text', [], []], [ - 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], - [{ byteStart: 5, byteEnd: 71 }], + 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + [{ byteStart: 5, byteEnd: 70 }], ], [ - 'body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab', + 'body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab', [], [], ], + [ + 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!', + ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + [{ byteStart: 5, byteEnd: 70 }], + ], [ 'its a #double#rainbow', ['double#rainbow'], [{ byteStart: 6, byteEnd: 21 }], ], ['##hashash', ['#hashash'], [{ byteStart: 0, byteEnd: 9 }]], + ['##', [], []], ['some #n0n3s@n5e!', ['n0n3s@n5e'], [{ byteStart: 5, byteEnd: 15 }]], [ 'works #with,punctuation', @@ -297,9 +306,47 @@ describe('detectFacets', () => { { byteStart: 17, byteEnd: 22 }, ], ], + ['this #️⃣tag should not be a tag', [], []], + [ + 'this ##️⃣tag should be a tag', + ['#️⃣tag'], + [ + { + byteStart: 5, + byteEnd: 16, + }, + ], + ], + [ + 'this #t\nag should be a tag', + ['t'], + [ + { + byteStart: 5, + byteEnd: 7, + }, + ], + ], + ['no match (\\u200B): #​', [], []], + ['no match (\\u200Ba): #​a', [], []], + ['match (a\\u200Bb): #a​b', ['a'], [{ byteStart: 18, byteEnd: 20 }]], + ['match (ab\\u200B): #ab​', ['ab'], [{ byteStart: 18, byteEnd: 21 }]], + ['no match (\\u20e2tag): #⃢tag', [], []], + ['no match (a\\u20e2b): #a⃢b', ['a'], [{ byteStart: 21, byteEnd: 23 }]], + [ + 'match full width number sign (tag): #tag', + ['tag'], + [{ byteStart: 36, byteEnd: 42 }], + ], + [ + 'match full width number sign (tag): ##️⃣tag', + ['#️⃣tag'], + [{ byteStart: 36, byteEnd: 49 }], + ], + ['no match 1?: #1?', [], []], ] - for (const [input, tags, indices] of inputs) { + it.each(inputs)('%s', async (input, tags, indices) => { const rt = new RichText({ text: input }) await rt.detectFacets(agent) @@ -318,7 +365,7 @@ describe('detectFacets', () => { expect(detectedTags).toEqual(tags) expect(detectedIndices).toEqual(indices) - } + }) }) }) diff --git a/packages/aws/CHANGELOG.md b/packages/aws/CHANGELOG.md index f8f0c521f18..6abcde64bd6 100644 --- a/packages/aws/CHANGELOG.md +++ b/packages/aws/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/aws +## 0.1.8 + +### Patch Changes + +- Updated dependencies []: + - @atproto/repo@0.3.8 + ## 0.1.7 ### Patch Changes diff --git a/packages/aws/package.json b/packages/aws/package.json index 55638b88552..c41003a4ace 100644 --- a/packages/aws/package.json +++ b/packages/aws/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/aws", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "description": "Shared AWS cloud API helpers for atproto services", "keywords": [ diff --git a/packages/bsky/CHANGELOG.md b/packages/bsky/CHANGELOG.md index 91a5493cb3f..102c40050eb 100644 --- a/packages/bsky/CHANGELOG.md +++ b/packages/bsky/CHANGELOG.md @@ -1,5 +1,30 @@ # @atproto/bsky +## 0.0.36 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + +## 0.0.35 + +### Patch Changes + +- Updated dependencies [[`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed)]: + - @atproto/api@0.10.3 + +## 0.0.34 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410), [`43531905c`](https://github.com/bluesky-social/atproto/commit/43531905ce1aec6d36d9be5943782811ecca6e6d), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410)]: + - @atproto/syntax@0.2.0 + - @atproto/api@0.10.2 + - @atproto/lexicon@0.3.2 + - @atproto/repo@0.3.8 + - @atproto/xrpc-server@0.4.3 + ## 0.0.33 ### Patch Changes diff --git a/packages/bsky/package.json b/packages/bsky/package.json index 099501296ed..dd081d3c209 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsky", - "version": "0.0.33", + "version": "0.0.36", "license": "MIT", "description": "Reference implementation of app.bsky App View (Bluesky API)", "keywords": [ diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index a675a0b0201..7e87cbb763e 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -903,6 +903,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.', diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index e18381d7b58..a860e6bcfa0 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -707,6 +707,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 diff --git a/packages/bsync/CHANGELOG.md b/packages/bsync/CHANGELOG.md new file mode 100644 index 00000000000..0b0476ae640 --- /dev/null +++ b/packages/bsync/CHANGELOG.md @@ -0,0 +1,8 @@ +# @atproto/bsync + +## 0.0.1 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4)]: + - @atproto/syntax@0.2.0 diff --git a/packages/bsync/package.json b/packages/bsync/package.json index 609c991f826..20520de17ba 100644 --- a/packages/bsync/package.json +++ b/packages/bsync/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsync", - "version": "0.0.0", + "version": "0.0.1", "license": "MIT", "description": "Sychronizing service for app.bsky App View (Bluesky API)", "keywords": [ diff --git a/packages/dev-env/CHANGELOG.md b/packages/dev-env/CHANGELOG.md index dd5af8ef139..15a6ad3171f 100644 --- a/packages/dev-env/CHANGELOG.md +++ b/packages/dev-env/CHANGELOG.md @@ -1,5 +1,39 @@ # @atproto/dev-env +## 0.2.36 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + - @atproto/bsky@0.0.36 + - @atproto/ozone@0.0.15 + - @atproto/pds@0.4.4 + +## 0.2.35 + +### Patch Changes + +- Updated dependencies [[`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed)]: + - @atproto/api@0.10.3 + - @atproto/bsky@0.0.35 + - @atproto/ozone@0.0.14 + - @atproto/pds@0.4.3 + +## 0.2.34 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410), [`43531905c`](https://github.com/bluesky-social/atproto/commit/43531905ce1aec6d36d9be5943782811ecca6e6d), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410)]: + - @atproto/syntax@0.2.0 + - @atproto/api@0.10.2 + - @atproto/bsky@0.0.34 + - @atproto/bsync@0.0.1 + - @atproto/lexicon@0.3.2 + - @atproto/ozone@0.0.13 + - @atproto/pds@0.4.2 + - @atproto/xrpc-server@0.4.3 + ## 0.2.33 ### Patch Changes diff --git a/packages/dev-env/package.json b/packages/dev-env/package.json index ee5165e9273..46899cdb763 100644 --- a/packages/dev-env/package.json +++ b/packages/dev-env/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/dev-env", - "version": "0.2.33", + "version": "0.2.36", "license": "MIT", "description": "Local development environment helper for atproto development", "keywords": [ diff --git a/packages/dev-env/src/ozone.ts b/packages/dev-env/src/ozone.ts index 4988a888f61..39cf26f5c81 100644 --- a/packages/dev-env/src/ozone.ts +++ b/packages/dev-env/src/ozone.ts @@ -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, @@ -62,7 +63,9 @@ export class TestOzone { const secrets = ozone.envToSecrets(env) // api server - const server = await ozone.OzoneService.create(cfg, secrets) + const server = await ozone.OzoneService.create(cfg, secrets, { + imgInvalidator: config.imgInvalidator, + }) await server.start() const daemon = await ozone.OzoneDaemon.create(cfg, secrets) diff --git a/packages/dev-env/src/types.ts b/packages/dev-env/src/types.ts index dbedfaced4f..83ce4475e10 100644 --- a/packages/dev-env/src/types.ts +++ b/packages/dev-env/src/types.ts @@ -34,6 +34,7 @@ export type OzoneConfig = Partial & { dbPostgresUrl: string migration?: string signingKey?: ExportableKeypair + imgInvalidator?: ozone.ImageInvalidator } export type TestServerParams = { diff --git a/packages/lex-cli/CHANGELOG.md b/packages/lex-cli/CHANGELOG.md index 4fa33448a6c..6570ca4f789 100644 --- a/packages/lex-cli/CHANGELOG.md +++ b/packages/lex-cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @atproto/lex-cli +## 0.3.1 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4)]: + - @atproto/syntax@0.2.0 + - @atproto/lexicon@0.3.2 + ## 0.3.0 ### Minor Changes diff --git a/packages/lex-cli/package.json b/packages/lex-cli/package.json index 8ad2aa3a5e5..3038c76ea92 100644 --- a/packages/lex-cli/package.json +++ b/packages/lex-cli/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/lex-cli", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "description": "TypeScript codegen tool for atproto Lexicon schemas", "keywords": [ diff --git a/packages/lexicon/CHANGELOG.md b/packages/lexicon/CHANGELOG.md index 24e2ea99a7d..0bd209b9659 100644 --- a/packages/lexicon/CHANGELOG.md +++ b/packages/lexicon/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/lexicon +## 0.3.2 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4)]: + - @atproto/syntax@0.2.0 + ## 0.3.1 ### Patch Changes diff --git a/packages/lexicon/package.json b/packages/lexicon/package.json index 4f0b05d20d8..610263e71a9 100644 --- a/packages/lexicon/package.json +++ b/packages/lexicon/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/lexicon", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "description": "atproto Lexicon schema language library", "keywords": [ diff --git a/packages/ozone/CHANGELOG.md b/packages/ozone/CHANGELOG.md index ca4d4954d97..0953945b4b2 100644 --- a/packages/ozone/CHANGELOG.md +++ b/packages/ozone/CHANGELOG.md @@ -1,5 +1,29 @@ # @atproto/ozone +## 0.0.15 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + +## 0.0.14 + +### Patch Changes + +- Updated dependencies [[`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed)]: + - @atproto/api@0.10.3 + +## 0.0.13 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410), [`43531905c`](https://github.com/bluesky-social/atproto/commit/43531905ce1aec6d36d9be5943782811ecca6e6d), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410)]: + - @atproto/syntax@0.2.0 + - @atproto/api@0.10.2 + - @atproto/lexicon@0.3.2 + - @atproto/xrpc-server@0.4.3 + ## 0.0.12 ### Patch Changes diff --git a/packages/ozone/package.json b/packages/ozone/package.json index e76dc644f10..3840f4b49ed 100644 --- a/packages/ozone/package.json +++ b/packages/ozone/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/ozone", - "version": "0.0.12", + "version": "0.0.15", "license": "MIT", "description": "Backend service for moderating the Bluesky network.", "keywords": [ diff --git a/packages/ozone/src/api/admin/emitModerationEvent.ts b/packages/ozone/src/api/admin/emitModerationEvent.ts index ef4c5fd2822..e8f65ab8dc7 100644 --- a/packages/ozone/src/api/admin/emitModerationEvent.ts +++ b/packages/ozone/src/api/admin/emitModerationEvent.ts @@ -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({ @@ -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) diff --git a/packages/ozone/src/api/label/subscribeLabels.ts b/packages/ozone/src/api/label/subscribeLabels.ts index 7efb339d488..701405f9dad 100644 --- a/packages/ozone/src/api/label/subscribeLabels.ts +++ b/packages/ozone/src/api/label/subscribeLabels.ts @@ -19,7 +19,7 @@ export default function (server: Server, ctx: AppContext) { } for await (const evt of outbox.events(cursor, signal)) { - yield evt + yield { $type: 'com.atproto.label.subscribeLabels#labels', ...evt } } }) } diff --git a/packages/ozone/src/config/config.ts b/packages/ozone/src/config/config.ts index 32ed8ba5cb5..8c25f8ace0b 100644 --- a/packages/ozone/src/config/config.ts +++ b/packages/ozone/src/config/config.ts @@ -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) @@ -38,6 +39,10 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => { did: env.pdsDid, } + const cdnCfg: OzoneConfig['cdn'] = { + paths: env.cdnPaths, + } + assert(env.didPlcUrl) const identityCfg: OzoneConfig['identity'] = { plcUrl: env.didPlcUrl, @@ -48,6 +53,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => { db: dbCfg, appview: appviewCfg, pds: pdsCfg, + cdn: cdnCfg, identity: identityCfg, } } @@ -57,6 +63,7 @@ export type OzoneConfig = { db: DatabaseConfig appview: AppviewConfig pds: PdsConfig | null + cdn: CdnConfig identity: IdentityConfig } @@ -65,6 +72,7 @@ export type ServiceConfig = { publicUrl: string did: string version?: string + devMode?: boolean } export type DatabaseConfig = { @@ -88,3 +96,7 @@ export type PdsConfig = { export type IdentityConfig = { plcUrl: string } + +export type CdnConfig = { + paths?: string[] +} diff --git a/packages/ozone/src/config/env.ts b/packages/ozone/src/config/env.ts index b0ad10074eb..7175c846839 100644 --- a/packages/ozone/src/config/env.ts +++ b/packages/ozone/src/config/env.ts @@ -1,8 +1,9 @@ -import { envInt, 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'), @@ -17,6 +18,7 @@ export const readEnv = (): OzoneEnvironment => { dbPoolMaxUses: envInt('OZONE_DB_POOL_MAX_USES'), dbPoolIdleTimeoutMs: envInt('OZONE_DB_POOL_IDLE_TIMEOUT_MS'), didPlcUrl: envStr('OZONE_DID_PLC_URL'), + cdnPaths: envList('OZONE_CDN_PATHS'), adminPassword: envStr('OZONE_ADMIN_PASSWORD'), moderatorPassword: envStr('OZONE_MODERATOR_PASSWORD'), triagePassword: envStr('OZONE_TRIAGE_PASSWORD'), @@ -26,6 +28,7 @@ export const readEnv = (): OzoneEnvironment => { export type OzoneEnvironment = { nodeEnv?: string + devMode?: boolean version?: string port?: number publicUrl?: string @@ -40,6 +43,7 @@ export type OzoneEnvironment = { dbPoolMaxUses?: number dbPoolIdleTimeoutMs?: number didPlcUrl?: string + cdnPaths?: string[] adminPassword?: string moderatorPassword?: string triagePassword?: string diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index 00ef4bd71ba..9701cc53d12 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -15,6 +15,7 @@ import { CommunicationTemplateService, CommunicationTemplateServiceCreator, } from './communication-service/template' +import { ImageInvalidator } from './image-invalidator' export type AppContextOptions = { db: Database @@ -25,6 +26,7 @@ export type AppContextOptions = { pdsAgent: AtpAgent | undefined signingKey: Keypair idResolver: IdResolver + imgInvalidator?: ImageInvalidator backgroundQueue: BackgroundQueue sequencer: Sequencer } @@ -56,8 +58,6 @@ 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, { @@ -65,20 +65,24 @@ export class AppContext { 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( diff --git a/packages/ozone/src/daemon/context.ts b/packages/ozone/src/daemon/context.ts index 5af19d89bc4..3ed0596c2ed 100644 --- a/packages/ozone/src/daemon/context.ts +++ b/packages/ozone/src/daemon/context.ts @@ -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 @@ -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({ diff --git a/packages/ozone/src/daemon/event-pusher.ts b/packages/ozone/src/daemon/event-pusher.ts index faaee4529ed..01570595c0d 100644 --- a/packages/ozone/src/daemon/event-pusher.ts +++ b/packages/ozone/src/daemon/event-pusher.ts @@ -205,7 +205,7 @@ export class EventPusher { ? { confirmedAt: new Date() } : { lastAttempted: new Date(), - attempts: evt.attempts ?? 0 + 1, + attempts: (evt.attempts ?? 0) + 1, }, ) .where('subjectDid', '=', evt.subjectDid) @@ -244,7 +244,7 @@ export class EventPusher { ? { confirmedAt: new Date() } : { lastAttempted: new Date(), - attempts: evt.attempts ?? 0 + 1, + attempts: (evt.attempts ?? 0) + 1, }, ) .where('subjectUri', '=', evt.subjectUri) @@ -284,7 +284,7 @@ export class EventPusher { ? { confirmedAt: new Date() } : { lastAttempted: new Date(), - attempts: evt.attempts ?? 0 + 1, + attempts: (evt.attempts ?? 0) + 1, }, ) .where('subjectDid', '=', evt.subjectDid) diff --git a/packages/ozone/src/image-invalidator.ts b/packages/ozone/src/image-invalidator.ts new file mode 100644 index 00000000000..788a1b1c6e3 --- /dev/null +++ b/packages/ozone/src/image-invalidator.ts @@ -0,0 +1,7 @@ +// Invalidation is a general interface for propagating an image blob +// takedown through any caches where a representation of it may be stored. +// @NOTE this does not remove the blob from storage: just invalidates it from caches. +// @NOTE keep in sync with same interface in aws/src/cloudfront.ts +export interface ImageInvalidator { + invalidate(subject: string, paths: string[]): Promise +} diff --git a/packages/ozone/src/index.ts b/packages/ozone/src/index.ts index cd06c62d65a..c1879745a78 100644 --- a/packages/ozone/src/index.ts +++ b/packages/ozone/src/index.ts @@ -13,6 +13,7 @@ import { createServer } from './lexicon' import AppContext, { AppContextOptions } from './context' export * from './config' +export { type ImageInvalidator } from './image-invalidator' export { Database } from './db' export { OzoneDaemon, EventPusher, EventReverser } from './daemon' export { AppContext } from './context' diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index a675a0b0201..7e87cbb763e 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -903,6 +903,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.', diff --git a/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts b/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts index e18381d7b58..a860e6bcfa0 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts @@ -707,6 +707,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 diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 0afbda79f0f..1ca793d5601 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -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, @@ -30,52 +34,67 @@ 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, RepoSubject, subjectFromStatusRow, } from './subject' +import { jsonb } from '../db/types' +import { LabelChannel } from '../db/schema/label' import { BlobPushEvent } from '../db/schema/blob_push_event' import { BackgroundQueue } from '../background' import { EventPusher } from '../daemon' -import { jsonb } from '../db/types' -import { LabelChannel } from '../db/schema/label' +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, 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, 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 { return await this.db.db @@ -283,6 +302,9 @@ export class ModerationService { if (isModEventEmail(event)) { meta.subjectLine = event.subjectLine + if (event.content) { + meta.content = event.content + } } const subjectInfo = subject.info() @@ -556,14 +578,38 @@ export class ModerationService { lastAttempted: null, }), ) - .returning('id') + .returning(['id', 'subjectDid', 'subjectBlobCid', 'eventType']) .execute() this.db.onCommit(() => { this.backgroundQueue.add(async () => { - await Promise.all( - blobEvts.map((evt) => this.eventPusher.attemptBlobEvent(evt.id)), + await Promise.allSettled( + blobEvts.map((evt) => + this.eventPusher + .attemptBlobEvent(evt.id) + .catch((err) => + log.error({ err, ...evt }, 'failed to push blob event'), + ), + ), ) + + if (this.imgInvalidator) { + await Promise.allSettled( + (subject.blobCids ?? []).map((cid) => { + const paths = (this.cdnPaths ?? []).map((path) => + path.replace('%s', subject.did).replace('%s', cid), + ) + return this.imgInvalidator + ?.invalidate(cid, paths) + .catch((err) => + log.error( + { err, paths, cid }, + 'failed to invalidate blob on cdn', + ), + ) + }), + ) + } }) }) } @@ -871,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] diff --git a/packages/ozone/src/mod-service/util.ts b/packages/ozone/src/mod-service/util.ts index dbb35d080eb..ec4de26c3ad 100644 --- a/packages/ozone/src/mod-service/util.ts +++ b/packages/ozone/src/mod-service/util.ts @@ -2,12 +2,16 @@ import { LabelRow } from '../db/schema/label' import { Label } from '../lexicon/types/com/atproto/label/defs' export const formatLabel = (row: LabelRow): Label => { - return { + const label: Label = { src: row.src, uri: row.uri, - cid: row.cid === '' ? undefined : row.cid, val: row.val, neg: row.neg, cts: row.cts, } + if (row.cid !== '') { + // @NOTE avoiding undefined values on label, which dag-cbor chokes on when serializing. + label.cid = row.cid + } + return label } diff --git a/packages/ozone/src/mod-service/views.ts b/packages/ozone/src/mod-service/views.ts index f1188968cbe..498091a8bd0 100644 --- a/packages/ozone/src/mod-service/views.ts +++ b/packages/ozone/src/mod-service/views.ts @@ -26,20 +26,17 @@ import { REASONOTHER } from '../lexicon/types/com/atproto/moderation/defs' import { subjectFromEventRow, subjectFromStatusRow } from './subject' import { formatLabel } from './util' -export type AppviewAuth = () => Promise< - | { - headers: { - authorization: string - } - } - | undefined -> +export type AuthHeaders = { + headers: { + authorization: string + } +} export class ModerationViews { constructor( private db: Database, private appviewAgent: AtpAgent, - private appviewAuth: AppviewAuth, + private appviewAuth: () => Promise, ) {} async getAccoutInfosByDid(dids: string[]): Promise> { @@ -154,6 +151,7 @@ export class ModerationViews { eventView.event = { ...eventView.event, subjectLine: event.meta?.subjectLine ?? '', + content: event.meta?.content, } } diff --git a/packages/ozone/tests/moderation-events.test.ts b/packages/ozone/tests/moderation-events.test.ts index 1778d239c76..626d5214896 100644 --- a/packages/ozone/tests/moderation-events.test.ts +++ b/packages/ozone/tests/moderation-events.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert' +import EventEmitter, { once } from 'node:events' +import Mail from 'nodemailer/lib/mailer' import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' import AtpAgent, { ComAtprotoAdminDefs, @@ -424,4 +426,51 @@ describe('moderation-events', () => { }) }) }) + + describe('email event', () => { + let sendMailOriginal + const mailCatcher = new EventEmitter() + const getMailFrom = async (promise): Promise => { + const result = await Promise.all([once(mailCatcher, 'mail'), promise]) + return result[0][0] + } + + beforeAll(() => { + const mailer = network.pds.ctx.moderationMailer + // Catch emails for use in tests + sendMailOriginal = mailer.transporter.sendMail + mailer.transporter.sendMail = async (opts) => { + const result = await sendMailOriginal.call(mailer.transporter, opts) + mailCatcher.emit('mail', opts) + return result + } + }) + + afterAll(() => { + network.pds.ctx.moderationMailer.transporter.sendMail = sendMailOriginal + }) + + it('sends email via pds.', async () => { + const mail = await getMailFrom( + emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventEmail', + comment: 'Reaching out to Alice', + subjectLine: 'Hello', + content: 'Hey Alice, how are you?', + }, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.alice, + }, + createdBy: sc.dids.bob, + }), + ) + expect(mail).toEqual({ + to: 'alice@test.com', + subject: 'Hello', + html: 'Hey Alice, how are you?', + }) + }) + }) }) diff --git a/packages/ozone/tests/moderation.test.ts b/packages/ozone/tests/moderation.test.ts index 790d0f5f820..323fa63eb30 100644 --- a/packages/ozone/tests/moderation.test.ts +++ b/packages/ozone/tests/moderation.test.ts @@ -25,6 +25,7 @@ import { } from '../src/lexicon/types/com/atproto/admin/defs' import { EventReverser } from '../src' import { TestOzone } from '@atproto/dev-env/src/ozone' +import { ImageInvalidator } from '../src/image-invalidator' import { UNSPECCED_TAKEDOWN_BLOBS_LABEL, UNSPECCED_TAKEDOWN_LABEL, @@ -43,6 +44,7 @@ type TakedownParams = BaseCreateReportParams & describe('moderation', () => { let network: TestNetwork let ozone: TestOzone + let mockInvalidator: MockInvalidator let agent: AtpAgent let bskyAgent: AtpAgent let pdsAgent: AtpAgent @@ -155,8 +157,13 @@ describe('moderation', () => { } beforeAll(async () => { + mockInvalidator = new MockInvalidator() network = await TestNetwork.create({ dbPostgresSchema: 'ozone_moderation', + ozone: { + imgInvalidator: mockInvalidator, + cdnPaths: ['/path1/%s/%s', '/path2/%s/%s'], + }, }) ozone = network.ozone agent = network.ozone.getClient() @@ -981,6 +988,18 @@ describe('moderation', () => { expect(await fetchImage.json()).toEqual({ message: 'Image not found' }) }) + it('invalidates the image in the cdn', async () => { + const blobCid = blob.image.ref.toString() + expect(mockInvalidator.invalidated.length).toBe(1) + expect(mockInvalidator.invalidated.at(0)?.subject).toBe(blobCid) + expect(mockInvalidator.invalidated.at(0)?.paths.at(0)).toEqual( + `/path1/${sc.dids.carol}/${blobCid}`, + ) + expect(mockInvalidator.invalidated.at(0)?.paths.at(1)).toEqual( + `/path2/${sc.dids.carol}/${blobCid}`, + ) + }) + it('fans takedown out to pds', async () => { const res = await pdsAgent.api.com.atproto.admin.getSubjectStatus( { @@ -1059,3 +1078,11 @@ describe('moderation', () => { }) }) }) + +class MockInvalidator implements ImageInvalidator { + invalidated: { subject: string; paths: string[] }[] = [] + + async invalidate(subject: string, paths: string[]) { + this.invalidated.push({ subject, paths }) + } +} diff --git a/packages/ozone/tests/query-labels.test.ts b/packages/ozone/tests/query-labels.test.ts index a3b51d30caa..2b4bf540450 100644 --- a/packages/ozone/tests/query-labels.test.ts +++ b/packages/ozone/tests/query-labels.test.ts @@ -1,6 +1,12 @@ import AtpAgent from '@atproto/api' import { TestNetwork } from '@atproto/dev-env' +import { DisconnectError, Subscription } from '@atproto/xrpc-server' +import { ids, lexicons } from '../src/lexicon/lexicons' import { Label } from '../src/lexicon/types/com/atproto/label/defs' +import { + OutputSchema as LabelMessage, + isLabels, +} from '../src/lexicon/types/com/atproto/label/subscribeLabels' describe('ozone query labels', () => { let network: TestNetwork @@ -121,4 +127,37 @@ describe('ozone query labels', () => { labels.slice(0, 5), ) }) + + describe('subscribeLabels', () => { + it('streams all labels from initial cursor.', async () => { + const ac = new AbortController() + let doneTimer: NodeJS.Timeout + const resetDoneTimer = () => { + clearTimeout(doneTimer) + doneTimer = setTimeout(() => ac.abort(new DisconnectError()), 100) + } + const sub = new Subscription({ + signal: ac.signal, + service: agent.service.origin.replace('http://', 'ws://'), + method: ids.ComAtprotoLabelSubscribeLabels, + getParams() { + return { cursor: 0 } + }, + validate(obj) { + return lexicons.assertValidXrpcMessage( + ids.ComAtprotoLabelSubscribeLabels, + obj, + ) + }, + }) + const streamedLabels: Label[] = [] + for await (const message of sub) { + resetDoneTimer() + if (isLabels(message)) { + streamedLabels.push(...message.labels) + } + } + expect(streamedLabels).toEqual(labels) + }) + }) }) diff --git a/packages/pds/CHANGELOG.md b/packages/pds/CHANGELOG.md index 8d2e19210b8..1a3e87286f3 100644 --- a/packages/pds/CHANGELOG.md +++ b/packages/pds/CHANGELOG.md @@ -1,5 +1,32 @@ # @atproto/pds +## 0.4.4 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + +## 0.4.3 + +### Patch Changes + +- Updated dependencies [[`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed)]: + - @atproto/api@0.10.3 + +## 0.4.2 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410), [`43531905c`](https://github.com/bluesky-social/atproto/commit/43531905ce1aec6d36d9be5943782811ecca6e6d), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410)]: + - @atproto/syntax@0.2.0 + - @atproto/api@0.10.2 + - @atproto/lexicon@0.3.2 + - @atproto/repo@0.3.8 + - @atproto/xrpc@0.4.2 + - @atproto/xrpc-server@0.4.3 + - @atproto/aws@0.1.8 + ## 0.4.1 ### Patch Changes diff --git a/packages/pds/package.json b/packages/pds/package.json index 74935d75057..062578aa531 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.4.1", + "version": "0.4.4", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index e23d6bea5c1..f6d8cce8d19 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -1,23 +1,23 @@ +import assert from 'node:assert' import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, resultPassthru } from '../../../proxy' +import { resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.sendEmail({ - auth: ctx.authVerifier.role, - handler: async ({ req, input, auth }) => { - if (!auth.credentials.admin && !auth.credentials.moderator) { + auth: ctx.authVerifier.roleOrAdminService, + handler: async ({ input, auth }) => { + if (auth.credentials.type === 'role' && !auth.credentials.moderator) { throw new AuthRequiredError('Insufficient privileges') } const { content, recipientDid, - senderDid, - subject = 'Message from Bluesky moderator', - comment, + subject = 'Message via your PDS', } = input.body + const account = await ctx.accountManager.getAccount(recipientDid, { includeDeactivated: true, includeTakenDown: true, @@ -27,11 +27,15 @@ export default function (server: Server, ctx: AppContext) { } if (ctx.entrywayAgent) { + assert(ctx.cfg.entryway) return resultPassthru( - await ctx.entrywayAgent.com.atproto.admin.sendEmail( - input.body, - authPassthru(req, true), - ), + await ctx.entrywayAgent.com.atproto.admin.sendEmail(input.body, { + encoding: 'application/json', + ...(await ctx.serviceAuthHeaders( + recipientDid, + ctx.cfg.entryway?.did, + )), + }), ) } @@ -44,24 +48,6 @@ export default function (server: Server, ctx: AppContext) { { subject, to: account.email }, ) - if (ctx.moderationAgent) { - await ctx.moderationAgent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventEmail', - subjectLine: subject, - comment, - }, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: recipientDid, - }, - createdBy: senderDid, - }, - { ...authPassthru(req), encoding: 'application/json' }, - ) - } - return { encoding: 'application/json', body: { sent: true }, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index a675a0b0201..7e87cbb763e 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -903,6 +903,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.', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index e18381d7b58..a860e6bcfa0 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -707,6 +707,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 diff --git a/packages/repo/CHANGELOG.md b/packages/repo/CHANGELOG.md index 4a52264ad23..2fa92cb0d39 100644 --- a/packages/repo/CHANGELOG.md +++ b/packages/repo/CHANGELOG.md @@ -1,5 +1,13 @@ # @atproto/repo +## 0.3.8 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4)]: + - @atproto/syntax@0.2.0 + - @atproto/lexicon@0.3.2 + ## 0.3.7 ### Patch Changes diff --git a/packages/repo/package.json b/packages/repo/package.json index 9badb9f9a42..d98206d3b87 100644 --- a/packages/repo/package.json +++ b/packages/repo/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/repo", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "description": "atproto repo and MST implementation", "keywords": [ diff --git a/packages/syntax/CHANGELOG.md b/packages/syntax/CHANGELOG.md index ad736e658a0..35ee5904c24 100644 --- a/packages/syntax/CHANGELOG.md +++ b/packages/syntax/CHANGELOG.md @@ -1,5 +1,11 @@ # @atproto/syntax +## 0.2.0 + +### Minor Changes + +- [#2223](https://github.com/bluesky-social/atproto/pull/2223) [`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4) Thanks [@bnewbold](https://github.com/bnewbold)! - allow colon character in record-key syntax + ## 0.1.5 ### Patch Changes diff --git a/packages/syntax/package.json b/packages/syntax/package.json index d6f0ea11fd6..0d1ae5fed7d 100644 --- a/packages/syntax/package.json +++ b/packages/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/syntax", - "version": "0.1.5", + "version": "0.2.0", "license": "MIT", "description": "Validation for atproto identifiers and formats: DID, handle, NSID, AT URI, etc", "keywords": [ diff --git a/packages/xrpc-server/CHANGELOG.md b/packages/xrpc-server/CHANGELOG.md index c96e2de140f..2ab33a7cc94 100644 --- a/packages/xrpc-server/CHANGELOG.md +++ b/packages/xrpc-server/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/xrpc-server +## 0.4.3 + +### Patch Changes + +- Updated dependencies []: + - @atproto/lexicon@0.3.2 + ## 0.4.2 ### Patch Changes diff --git a/packages/xrpc-server/package.json b/packages/xrpc-server/package.json index b68ecb03ea4..b6093503d0b 100644 --- a/packages/xrpc-server/package.json +++ b/packages/xrpc-server/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/xrpc-server", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "description": "atproto HTTP API (XRPC) server library", "keywords": [ diff --git a/packages/xrpc/CHANGELOG.md b/packages/xrpc/CHANGELOG.md index 69977ee06d3..1a4aeb25df3 100644 --- a/packages/xrpc/CHANGELOG.md +++ b/packages/xrpc/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/xrpc +## 0.4.2 + +### Patch Changes + +- Updated dependencies []: + - @atproto/lexicon@0.3.2 + ## 0.4.1 ### Patch Changes diff --git a/packages/xrpc/package.json b/packages/xrpc/package.json index e2accc2750d..2c8977d4f12 100644 --- a/packages/xrpc/package.json +++ b/packages/xrpc/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/xrpc", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "description": "atproto HTTP API (XRPC) client library", "keywords": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ec8d78ae3b..a1610aac275 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -962,6 +962,9 @@ importers: services/ozone: dependencies: + '@atproto/aws': + specifier: workspace:^ + version: link:../../packages/aws '@atproto/ozone': specifier: workspace:^ version: link:../../packages/ozone diff --git a/services/bsky/Dockerfile b/services/bsky/Dockerfile index 84422945ae0..a15e6aa2ee7 100644 --- a/services/bsky/Dockerfile +++ b/services/bsky/Dockerfile @@ -35,7 +35,10 @@ WORKDIR services/bsky # Uses assets from build stage to reduce build size FROM node:20.11-alpine -RUN apk add --update dumb-init +# dumb-init is used to handle signals properly. +# runit is installed so it can be (optionally) used for logging via svlogd. +RUN apk add --update dumb-init runit + # Avoid zombie processes, handle signal forwarding ENTRYPOINT ["dumb-init", "--"] diff --git a/services/ozone/Dockerfile b/services/ozone/Dockerfile index 04fa0e851c7..5ecfb06f746 100644 --- a/services/ozone/Dockerfile +++ b/services/ozone/Dockerfile @@ -8,10 +8,12 @@ COPY ./*.* ./ # NOTE ozones's transitive dependencies go here: if that changes, this needs to be updated. COPY ./packages/ozone ./packages/ozone COPY ./packages/api ./packages/api +COPY ./packages/aws ./packages/aws COPY ./packages/common ./packages/common COPY ./packages/common-web ./packages/common-web COPY ./packages/crypto ./packages/crypto COPY ./packages/identity ./packages/identity +COPY ./packages/repo ./packages/repo COPY ./packages/syntax ./packages/syntax COPY ./packages/lexicon ./packages/lexicon COPY ./packages/xrpc ./packages/xrpc diff --git a/services/ozone/api.js b/services/ozone/api.js index f8d3f48f8f5..a58e8e53cab 100644 --- a/services/ozone/api.js +++ b/services/ozone/api.js @@ -12,6 +12,11 @@ require('dd-trace') // Only works with commonjs // Tracer code above must come before anything else const path = require('path') +const { + BunnyInvalidator, + CloudfrontInvalidator, + MultiImageInvalidator, +} = require('@atproto/aws') const { OzoneService, envToCfg, @@ -24,7 +29,38 @@ const main = async () => { const env = readEnv() const cfg = envToCfg(env) const secrets = envToSecrets(env) - const ozone = await OzoneService.create(cfg, secrets) + + // configure zero, one, or more image invalidators + const imgUriEndpoint = process.env.OZONE_IMG_URI_ENDPOINT + const bunnyAccessKey = process.env.OZONE_BUNNY_ACCESS_KEY + const cfDistributionId = process.env.OZONE_CF_DISTRIBUTION_ID + + const imgInvalidators = [] + + if (bunnyAccessKey) { + imgInvalidators.push( + new BunnyInvalidator({ + accessKey: bunnyAccessKey, + urlPrefix: imgUriEndpoint, + }), + ) + } + + if (cfDistributionId) { + imgInvalidators.push( + new CloudfrontInvalidator({ + distributionId: cfDistributionId, + pathPrefix: imgUriEndpoint && new URL(imgUriEndpoint).pathname, + }), + ) + } + + const imgInvalidator = + imgInvalidators.length > 1 + ? new MultiImageInvalidator(imgInvalidators) + : imgInvalidators[0] + + const ozone = await OzoneService.create(cfg, secrets, { imgInvalidator }) await ozone.start() diff --git a/services/ozone/package.json b/services/ozone/package.json index bc959ff8e4d..e5f31bec469 100644 --- a/services/ozone/package.json +++ b/services/ozone/package.json @@ -2,6 +2,7 @@ "name": "ozone-service", "private": true, "dependencies": { + "@atproto/aws": "workspace:^", "@atproto/ozone": "workspace:^", "dd-trace": "3.13.2" }