Compare commits

...
Author SHA1 Message Date
Hampus bd4117fa0a feat(api): stream harvest downloads from the configured s3 bucket (#1979) 2026-08-24 14:39:05 +02:00
Hampus f004685424 fix(repo): make the workspace lint, typecheck and test clean (#1978) 2026-08-24 13:35:22 +02:00
Hampus b268320406 chore(i18n): re-extract the message catalogs for the new strings (#1977) 2026-08-24 12:56:47 +02:00
Hampus 7a33b3198a fix(app): stop reprobing image format support on every build (#1976) 2026-08-24 12:56:26 +02:00
Hampus 66791d3ca2 fix(app): stop fetching a tiny avatar for the large voice tile (#1975) 2026-08-24 12:56:16 +02:00
Hampus a0d4a33fd4 fix(app): write the first voice session restore snapshot on startup (#1974) 2026-08-24 12:56:04 +02:00
Hampus fdd12194b1 fix(app): record voice messages in a widely playable container (#1973) 2026-08-24 12:55:53 +02:00
Hampus bf11445299 fix(app): terminate the voice e2ee worker with the room that owns it (#1972) 2026-08-24 12:55:42 +02:00
Hampus 61bf8f6ad3 perf(app): load voice background tiles only near the viewport (#1971) 2026-08-24 12:55:32 +02:00
Hampus 52282bfccf refactor(app): collapse the wrapped voice avatar render branches into one (#1970) 2026-08-24 12:55:22 +02:00
Hampus cf3a31ac42 perf(app): keep more twemoji urls cached instead of dropping them all (#1969) 2026-08-24 12:55:11 +02:00
Hampus f56ccf5ef5 fix(app): keep tooltips inside the fullscreen element (#1968) 2026-08-24 12:55:00 +02:00
Hampus 51e2eee15a fix(app): stop aborting shared uploads when cancelling one attachment (#1967) 2026-08-24 12:54:50 +02:00
Hampus de97bf908d fix(app): accept tenor links with a regional locale prefix (#1966) 2026-08-24 12:54:39 +02:00
Hampus 099fb80da2 perf(app): stop double-loading stream previews into the image cache (#1965) 2026-08-24 12:54:29 +02:00
Hampus 022ccc794d perf(app): render guild stickers only near the modal viewport (#1964) 2026-08-24 12:54:19 +02:00
Hampus 987768e8dc fix(app): only animate stickers that actually have animation (#1963) 2026-08-24 12:54:08 +02:00
Hampus a3ffe963c3 fix(app): inherit the parent gap inside stepped carousel panes (#1962) 2026-08-24 12:53:58 +02:00
Hampus b51562d0e3 fix(app): keep focus and its ring on a spoiler after it is revealed (#1961) 2026-08-24 12:53:47 +02:00
Hampus 38e86acffe fix(app): let a sound restart immediately after being stopped (#1960) 2026-08-24 12:53:34 +02:00
Hampus 38a299d852 fix(app): show community avatars in the search user filter (#1959) 2026-08-24 12:53:20 +02:00
Hampus 55b25c919d fix(app): announce search result counts to screen readers (#1958) 2026-08-24 12:53:10 +02:00
Hampus 2af4cc98fd feat(app): suggest filters and people while typing search text (#1957) 2026-08-24 12:52:58 +02:00
Hampus e68b5b8b5a fix(app): stop animations running under reduced motion (#1956) 2026-08-24 12:52:47 +02:00
Hampus 317b4e26c0 fix(app): stop the pointer hijacking quick switcher selection (#1955) 2026-08-24 12:52:36 +02:00
Hampus af5bee9149 fix(app): stop refetching profile art when menus appear (#1954) 2026-08-24 12:52:26 +02:00
Hampus 26939eccce fix(app): let the profile popout banner fill its header (#1953) 2026-08-24 12:52:16 +02:00
Hampus 54360708d9 fix(app): stop rewriting profile bio emoji urls on hover (#1952) 2026-08-24 12:52:06 +02:00
Hampus e441c7229c fix(app): version profile badge assets so they refresh (#1951) 2026-08-24 12:51:56 +02:00
Hampus 54e5fe6ef9 fix(app): stop premium upsell preview emoji from stretching (#1950) 2026-08-24 12:51:45 +02:00
Hampus 6fc0f1ccd7 fix(app): remove popout stylesheets when the main document drops them (#1949) 2026-08-24 12:51:35 +02:00
Hampus 6cd30d893a feat(app): let the pinned messages popout be resized (#1948) 2026-08-24 12:51:25 +02:00
Hampus dceb9061d9 perf(app): recompute picker grid layout in scroll chunks (#1947) 2026-08-24 12:51:13 +02:00
Hampus 21438b2d8f fix(app): fade the picker thumbhash out instead of fading images in (#1946) 2026-08-24 12:50:59 +02:00
Hampus 793e853eb3 fix(app): fit media to its real box and gate the zoom buttons (#1945) 2026-08-24 12:50:48 +02:00
Hampus 9cbe0efbbb fix(app): rank permission override member search by the worker order (#1944) 2026-08-24 12:50:27 +02:00
Hampus 9219b886fe fix(app): add a quick modal motion preset and settle instant modals (#1943) 2026-08-24 12:50:16 +02:00
Hampus 2377a4c9d0 fix(app): honour motion settings in the mobile meme picker (#1942) 2026-08-24 12:50:06 +02:00
Hampus 986c586683 fix(app): show message actions only on keyboard focus (#1941) 2026-08-24 12:49:52 +02:00
Hampus 7be52fa9fc perf(app): stop redundant member list presence updates (#1940) 2026-08-24 12:49:40 +02:00
Hampus 32d7ae3eef fix(app): track member list width with a media query (#1939) 2026-08-24 12:49:29 +02:00
Hampus ef6fb4903f perf(app): redraw the media timestamp only when the second changes (#1938) 2026-08-24 12:49:19 +02:00
Hampus 0fe3f7523d fix(app): leave fullscreen when the video player unmounts (#1937) 2026-08-24 12:49:08 +02:00
Hampus 9991534c83 perf(app): reuse parsed markdown across message renders (#1936) 2026-08-24 12:48:57 +02:00
Hampus 455681b24c fix(app): build settings preview state lazily at first use (#1935) 2026-08-24 12:48:47 +02:00
Hampus 14e63085dd feat(app): resize the inbox popout from any edge (#1934) 2026-08-24 12:48:36 +02:00
Hampus e8cb1cefbd fix(app): reject oversized images before cropping an emoji or sticker (#1933) 2026-08-24 12:48:23 +02:00
Hampus 919e890011 fix(app): show guild initials until the guild icon has painted (#1932) 2026-08-24 12:48:11 +02:00
Hampus 299172c8aa fix(app): animate guild icons through the shared motion gate (#1931) 2026-08-24 12:47:59 +02:00
Hampus 6cac93ef39 fix(app): use guild initials for community picker rows (#1930) 2026-08-24 12:47:49 +02:00
Hampus e5c8ef7d60 perf(app): stop loading the animated guild banner where it is hidden (#1929) 2026-08-24 12:47:37 +02:00
Hampus d0b4688a6c perf(app): reuse pooled gif videos instead of caching blobs (#1928) 2026-08-24 12:47:13 +02:00
Hampus e0464ee5be fix(app): pick gif picker previews by format and skip empty sources (#1927) 2026-08-24 12:47:01 +02:00
Hampus cbcd299bd9 fix(app): keep a child's own data-flx when wrapped in a focus ring (#1926) 2026-08-24 12:46:51 +02:00
Hampus 1300e5a690 fix(app): refresh favorite gif previews that point at a provider page (#1925) 2026-08-24 12:46:40 +02:00
Hampus fbd653d8e0 feat(app): resize the expression picker and snap to emoji columns (#1924) 2026-08-24 12:46:30 +02:00
Hampus eb4f41e80c perf(app): preload picker images through the shared image cache (#1923) 2026-08-24 12:46:19 +02:00
Hampus 589a01a2da fix(app): load expression grid images only when they scroll into view (#1922) 2026-08-24 12:46:08 +02:00
Hampus aae6b99761 fix(app): stop the skin tone selector listening while closed (#1921) 2026-08-24 12:45:57 +02:00
Hampus 5d35047734 fix(app): reserve emoji picker cells while their image loads (#1920) 2026-08-24 12:45:47 +02:00
Hampus 98d10215d6 fix(app): blur mature embed images and videos (#1919) 2026-08-24 12:45:36 +02:00
Hampus 6656628bba fix(app): load small list avatars eagerly (#1918) 2026-08-24 12:45:24 +02:00
Hampus 37f46fc62d fix(app): hide decorative country and region flags from screen readers (#1917) 2026-08-24 12:45:13 +02:00
Hampus 9efd2b0a73 fix(app): replace the whole +: token when picking a reaction emoji (#1916) 2026-08-24 12:45:02 +02:00
Hampus b1fb2c14a1 fix(app): keep composer markdown highlighting aligned on long messages (#1915) 2026-08-24 12:44:51 +02:00
Hampus c5d910425e fix(app): honour the animation setting for composer custom emoji (#1914) 2026-08-24 12:44:40 +02:00
Hampus 0a9e64d36a fix(app): keep the character counter inside the composer box (#1913) 2026-08-24 12:44:30 +02:00
Hampus 7d02b7959f fix(app): honour motion settings in composer autocomplete previews (#1912) 2026-08-24 12:44:19 +02:00
Hampus 0670380360 fix(app): reuse highlighted code instead of flashing plain text (#1911) 2026-08-24 12:44:09 +02:00
Hampus 904987795a fix(app): let custom branding override the built-in meta description (#1910) 2026-08-24 12:43:58 +02:00
Hampus 4ee1b2294a fix(app): show a still ban image under reduced motion or data saver (#1909) 2026-08-24 12:43:47 +02:00
Hampus 97eb84fd46 fix(app): keep avatar stack entries keyed by user across re-renders (#1908) 2026-08-24 12:43:37 +02:00
Hampus 5eb7822691 fix(app): show cached auth splash art without a fade-in flash (#1907) 2026-08-24 12:43:26 +02:00
Hampus 79cf57abe4 perf(app): keep gateway and layer manager off the auth session path (#1906) 2026-08-24 12:43:15 +02:00
Hampus 075bdb5128 fix(app): size auth entity icons to their reserved box (#1905) 2026-08-24 12:43:04 +02:00
Hampus 65698051ac fix(app): keep paging when the jumped-to message is missing (#1904) 2026-08-24 12:42:54 +02:00
Hampus 95559f17d8 fix(app): drive the message list window from one load state machine (#1903) 2026-08-24 12:42:42 +02:00
Hampus aabc13e3cb perf(app): window sticker picker rows by offset instead of observers (#1902) 2026-08-24 12:42:31 +02:00
Hampus b71ced9b2e fix(app): tell the user when a search query has nothing to search (#1901) 2026-08-24 12:42:20 +02:00
Hampus bb34c73069 perf(app): load sheet and popout media against their own scrollers (#1900) 2026-08-24 12:42:09 +02:00
Hampus 94bbbd1021 fix(app): only track hover on reactions that have an animated emoji (#1899) 2026-08-24 12:41:58 +02:00
Hampus 670a3a970e fix(app): stop pickers re-slicing their grid on first paint (#1898) 2026-08-24 12:41:47 +02:00
Hampus 7a938c85f6 fix(app): stop rewriting message emoji urls to toggle animation (#1897) 2026-08-24 12:41:36 +02:00
Hampus 3a6bc4bc7b perf(app): show the message action bar from css not remounting (#1896) 2026-08-24 12:41:24 +02:00
Hampus c54d7bcc88 perf(app): render member list rows in scroll chunks (#1895) 2026-08-24 12:41:14 +02:00
Hampus b81bfc80fd fix(app): stop the inbox reshuffling unread channels as you read them (#1894) 2026-08-24 12:41:03 +02:00
Hampus d8bad50eec perf(app): defer video embed metadata probes until hover (#1893) 2026-08-24 12:40:53 +02:00
Hampus 3fe6217809 fix(app): keep animated embed images animated (#1892) 2026-08-24 12:40:41 +02:00
Hampus 50a947a722 fix(app): fade image embed placeholders out instead of images in (#1891) 2026-08-24 12:40:29 +02:00
Hampus 7fe5aad16e feat(app): suggest recent speakers and stop autocomplete list flicker (#1890) 2026-08-24 12:40:19 +02:00
Hampus 97d559f749 fix(app): label pending composer attachments for screen readers (#1889) 2026-08-24 12:40:07 +02:00
Hampus 188f783fae fix(app): request attachment images at their real mosaic tile size (#1888) 2026-08-24 12:39:56 +02:00
Hampus 3a064dd728 feat(app): render search filters as inline tokens you can type through (#1887) 2026-08-24 12:39:45 +02:00
Hampus 202856c0f7 feat(app): keep search history per conversation and allow in: in DMs (#1886) 2026-08-24 12:39:34 +02:00
Hampus 406340fa65 fix(app): open channels at the unread divider and settle jump scrolling (#1885) 2026-08-24 12:39:23 +02:00
Hampus 735ecc1cca fix(api): distinguish a deleted reply target from no reply (#1884) 2026-08-24 12:39:11 +02:00
Hampus 7b646f891e fix(app): animate reaction emoji only while hovering them (#1883) 2026-08-24 12:37:48 +02:00
Hampus 19264d7f81 perf(app): preload reaction menu emoji at the size they render (#1882) 2026-08-24 12:37:38 +02:00
Hampus 76ce060d13 fix(app): stop the quick switcher list rebuilding while you navigate (#1881) 2026-08-24 12:37:27 +02:00
Hampus 9b3dc19037 perf(app): load the keyboard mode intro modal only when it is shown (#1880) 2026-08-24 12:37:12 +02:00
Hampus 5821a68816 fix(app): show the server avatar on message notifications (#1879) 2026-08-24 12:37:01 +02:00
Hampus e21544eac2 fix(app): order member mention results by search relevance (#1878) 2026-08-24 12:36:50 +02:00
Hampus 1f0f7d1222 perf(app): play gif embeds from the viewport not a js decoder (#1877) 2026-08-24 12:36:38 +02:00
Hampus 69e0086144 feat(app): warm full-size media while hovering an attachment (#1876) 2026-08-24 12:36:27 +02:00
Hampus 61ce8729de fix(app): stop overlaying a sharpening canvas in the media viewer (#1875) 2026-08-24 12:36:15 +02:00
Hampus 44869b0ce4 fix(app): stop seeking and hidden tabs from stranding playback (#1874) 2026-08-24 12:35:58 +02:00
Hampus 213dd53ee8 fix(app): size youtube embeds with the shared embed limits (#1873) 2026-08-24 12:35:42 +02:00
Hampus 844952db51 feat(app): drop the compact attachments media size preference (#1872) 2026-08-24 12:35:28 +02:00
Hampus eda29c0e34 fix(app): contain embedded media inside its box instead of overflowing (#1871) 2026-08-24 12:35:15 +02:00
Hampus 5834e32aa5 perf(app): window emoji picker rows by offset instead of observers (#1870) 2026-08-24 12:35:02 +02:00
Hampus a59f3d0d0c fix(app): animate message emoji only while hovering the message (#1869) 2026-08-24 12:34:52 +02:00
Hampus 5b8a46d087 fix(app): draw the mention badge cutout from the badge itself (#1868) 2026-08-24 12:34:37 +02:00
Hampus 677e6e5d6e fix(app): stop animating emoji and stickers that have no animation (#1867) 2026-08-24 12:34:26 +02:00
Hampus 62f40116be fix(app): stop discovery animating on first render (#1866) 2026-08-24 12:34:15 +02:00
Hampus d2d199e136 perf(app): fetch text attachment previews only when they scroll near (#1865) 2026-08-24 12:34:04 +02:00
Hampus 39e2c89d5c fix(app): only animate media that has an animated variant (#1864) 2026-08-24 12:33:53 +02:00
Hampus 15f4417c68 fix(app): scope near-viewport loading to the surrounding scroller (#1863) 2026-08-24 12:33:39 +02:00
Hampus 943b948de7 fix(app): keep hydrated state across gateway session resumes (#1862) 2026-08-24 12:33:27 +02:00
Hampus b7f75e25ad fix(app): keep unsent messages and stop the unread jump on reconnect (#1861) 2026-08-24 12:33:17 +02:00
Hampus 2af0405317 fix(app): keep the chat scroller pinned to the end while it resizes (#1860) 2026-08-24 12:33:06 +02:00
Hampus a2099fb0e2 refactor(app): name mute and unread state accessors by intent (#1859) 2026-08-24 12:32:55 +02:00
Hampus 52781cfa2d refactor(app): drop the unread jump anchor now the scroller finds it (#1858) 2026-08-24 12:32:44 +02:00
Hampus af7469fa1f fix(app): request custom emoji at one canonical image size (#1857) 2026-08-24 12:32:28 +02:00
Hampus 4c14ba0a5e fix(app): paint avatars from a real image with a fading placeholder (#1856) 2026-08-24 12:32:14 +02:00
Hampus b49cf349a8 perf(app): load images immediately instead of queueing four at a time (#1855) 2026-08-24 12:32:02 +02:00
Hampus 02406925d7 refactor(app): collapse the duplicate emoji shortcode matchers into one (#1854) 2026-08-24 12:31:51 +02:00
Hampus aad7e1a551 fix(app): freeze embed author and footer icons under motion settings (#1853) 2026-08-24 12:31:40 +02:00
Hampus a98a9fe6a9 feat(app): understand date words and DM channels in search filters (#1852) 2026-08-24 12:31:29 +02:00
Hampus ac6fcc8c40 fix(app): close the modal you opened, not whatever is on top (#1851) 2026-08-24 12:31:14 +02:00
Hampus b0e882645e fix(app): match member search on accents, display names and ids (#1850) 2026-08-24 12:31:02 +02:00
Hampus 8c431c7562 fix(app): retry hydrating message authors after a reconnect (#1849) 2026-08-24 12:30:50 +02:00
Hampus 3e267b8104 fix(app): keep the member list in sync when switching channels (#1848) 2026-08-24 12:30:38 +02:00
Hampus 7c5cab2144 fix(app): make the media volume slider track loudness (#1847) 2026-08-24 12:30:13 +02:00
Hampus 5cb893245f fix(app): hide the video controls again after a few idle seconds (#1846) 2026-08-24 12:30:01 +02:00
Hampus aa1c636cc2 fix(app): keep local image previews from disappearing before upload (#1845) 2026-08-24 12:29:49 +02:00
Hampus c285854b71 refactor(app): rename ComponentDispatch to ComponentBus (#1844) 2026-08-24 12:29:38 +02:00
Hampus 01a29d629b fix(app): size the chat skeleton from the remembered viewport height (#1843) 2026-08-24 12:29:26 +02:00
Hampus bd381ccc74 fix(app): request avatar and banner sizes the media proxy serves (#1842) 2026-08-24 12:29:16 +02:00
Hampus c3e747aaa4 fix(media-proxy): stop re-encoding jpeg and video frames losslessly (#1841) 2026-08-24 12:29:05 +02:00
Hampus 480d56125d fix(admin): render sticker previews at 320px instead of 160px (#1840) 2026-08-24 12:28:54 +02:00
Hampus 7ec155eac1 fix(desktop): stop reading whole voice backgrounds to classify them (#1839) 2026-08-24 12:28:43 +02:00
Hampus c8ff177f85 fix(unfurl): cache empty results briefly and key entries by provider (#1838) 2026-08-24 12:28:32 +02:00
Hampus f276bb1cd2 perf(app): stop the service worker caching static assets (#1837) 2026-08-24 12:28:21 +02:00
Hampus 208632e648 fix(common): stop advertising static assets as immutable (#1836) 2026-08-24 12:28:10 +02:00
Hampus 8cfac127f5 fix(media-proxy): stop proxy paths carrying fragments or credentials (#1835) 2026-08-24 12:27:58 +02:00
Hampus ac76bee5a3 fix(media-proxy): clamp the webp effort override to the encoder maximum (#1834) 2026-08-24 12:27:46 +02:00
Hampus 171dc7e5b7 fix(media-proxy): serve and request images on one size ladder (#1833) 2026-08-24 12:27:33 +02:00
Hampus 051985b767 fix(media-proxy): stop marking cached media immutable (#1832) 2026-08-24 12:27:21 +02:00
Hampus 1eb85410bf fix(common): keep the text after a block spoiler's closing pipes (#1831) 2026-08-24 12:27:08 +02:00
Hampus 6697db7cf8 build(app): minify css with lightningcss in production builds (#1830) 2026-08-24 12:26:55 +02:00
Hampus 56f6009390 feat(gifs): serve every medium, tiny and nano variant klipy offers (#1829) 2026-08-24 12:26:44 +02:00
Hampus d339b82f8e fix(api): give desktop downloads a lifetime that follows the key (#1828) 2026-08-24 12:26:33 +02:00
Hampus 82eb87bc47 perf(app-proxy): preconnect the media origin and revalidate the app shell (#1827) 2026-08-24 12:26:21 +02:00
Hampus 991be1c5a2 fix(api): log every error once and keep the cause chain (#1826) 2026-08-24 12:11:42 +02:00
Hampus 0d96a4574a fix(api): read the log level and environment from the process env (#1824) 2026-08-23 21:44:32 +02:00
Hampus 61b4511ae4 fix(schema): group category channels text before voice when ordering (#1823) 2026-08-23 17:49:26 +02:00
Hampus 137edc7cfb fix(app): share stream audio by default and reset the opt-out per stream (#1819) 2026-08-22 10:14:45 +02:00
Hampus 14e772a751 fix(api): serialise elapsed temp bans as null for the admin panel (#1817) 2026-08-21 20:34:43 +02:00
Hampus 32afbf12d6 fix(api): stop treating accounts pending deletion as already deleted (#1816) 2026-08-21 20:31:32 +02:00
Hampus ffaf5119d8 fix(app): only warn about software encoding when no layer is accelerated (#1815) 2026-08-21 18:49:51 +02:00
Hampus 10bc8c1efa fix(desktop): stop the windows audio probe timing out against its own budget (#1814) 2026-08-21 17:29:12 +02:00
Hampus 85a03a9e39 fix(app): apply the screen share audio toggle to the surface being shared (#1813) 2026-08-21 17:04:22 +02:00
fluxer-ci[bot]andhampus-fluxer 51ee6567b4 chore(i18n): update public marketing catalogs (#1812)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:31:01 +02:00
fluxer-ci[bot]andhampus-fluxer 090220a29d chore(marketing): advance pointer f39eced → 02e3a2c (#1811)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:30:54 +02:00
Hampus e7973b8be0 fix(api): derive voice reconciliation candidate ttl from real sweep spacing (#1810) 2026-08-21 15:49:50 +02:00
Hampus b5324c9223 perf(gateway): stop materializing all members on the guild connect path (#1809) 2026-08-21 14:00:19 +02:00
Hampus edb8d80077 ci: source the s3 provider for downloads and static from repo variables (#1803) 2026-08-20 21:46:26 +02:00
Hampus ddee116339 feat(api): route downloads through the configured downloads provider (#1802) 2026-08-20 21:44:53 +02:00
Hampus bdacaea4a8 feat(config): add an optional separate s3 provider for downloads (#1801) 2026-08-20 21:34:19 +02:00
Hampus 9e28e02b5d ci(rust): pin the floating toolchains to the version images build with (#1800) 2026-08-20 21:27:12 +02:00
Hampus 3527dc95a2 fix(rust): silence result_large_err on axum response error paths (#1799) 2026-08-20 21:18:18 +02:00
Hampus 27c7b2722d chore(admin): regenerate openapi schemas for the admin acl cap (#1798) 2026-08-20 21:17:04 +02:00
Hampus ba96f52ed6 fix(schema): allow assigning every admin ACL to a user (#1797) 2026-08-20 21:09:56 +02:00
Hampus 2c8b3ff45c fix(build): build the messages and users images with scylla support (#1796) 2026-08-20 20:30:16 +02:00
Hampus 631bc2307a fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795) 2026-08-20 19:56:02 +02:00
Hampus 8f4f9a8601 refactor(media): share one external proxy url codec across every service (#1794) 2026-08-20 19:55:40 +02:00
Hampus 1c920f966e feat(media): emit external proxy urls with a readable path and extension (#1793) 2026-08-20 18:44:45 +02:00
Hampus 2b1de38949 chore(i18n): regenerate catalogs after the voice engine removal (#1791) 2026-08-20 17:45:49 +02:00
Hampus d5daf61dbd fix(voice): recalibrate stored participant and stream volumes too (#1790) 2026-08-20 17:45:17 +02:00
Hampus 06c42ce02f refactor(desktop): delete the unused rust voice module (#1789) 2026-08-20 17:44:55 +02:00
Hampus 4f21430880 refactor(voice): drop the native only surface from voice_engine_v2 and narrow the desktop bridge (#1788) 2026-08-20 17:44:37 +02:00
Hampus 9731bac40f refactor(voice): remove the native voice engine from the renderer (#1787) 2026-08-20 17:43:38 +02:00
Hampus b144e650f2 fix(voice): make deafen reach watched screen share audio (#1786) 2026-08-20 17:43:19 +02:00
Hampus ef6536644c fix(voice): stop the in call speaker slider clamping a boosted output volume (#1785) 2026-08-20 17:42:59 +02:00
Hampus 17eab43245 fix(voice): retune the remote playback leveller and measure it in float (#1784) 2026-08-20 17:42:40 +02:00
Hampus fbd7f1e3b8 fix(voice): compose participant gain in linear space and split the ceilings (#1783) 2026-08-20 17:42:21 +02:00
Hampus 25af7516a4 fix(voice): widen the volume boost leg and add a master soft clip limiter (#1782) 2026-08-20 17:42:02 +02:00
Hampus c020eded31 fix(voice): stop forcing automatic gain control off on every capture profile (#1781) 2026-08-20 17:41:44 +02:00
Hampus 5331c0216a fix(voice): keep a remote track pinned at zero volume across re-attach (#1780) 2026-08-20 17:40:53 +02:00
Hampus 528777926c fix(api): stop one unreachable community from breaking bookmarks (#1779) 2026-08-20 15:50:51 +02:00
Hampus 47b5c3d4f0 fix(app): expose the guild id on guild list items again (#1778) 2026-08-20 14:39:24 +02:00
Hampus d9bfca66d6 fix(app): keep search results in server order instead of per channel (#1777) 2026-08-20 13:10:47 +02:00
Hampus ded51b65d3 fix(app): restore the mobile voice message recorder (#1776) 2026-08-20 04:33:24 +02:00
Hampus ddc8837d4f fix(app): scale embed and attachment width caps with zoom (#1775) 2026-08-20 04:33:17 +02:00
Hampus df16957c95 fix(app): scale tooltip max-width with app zoom (#1774) 2026-08-20 04:33:10 +02:00
Hampus f38b19d4bd fix(app): let the caret move past emoji and mentions in the composer (#1768) 2026-08-19 23:41:05 +02:00
Hampus f7cd4f2c74 docs(operator): explain how to reclaim space after upgrades (#1767) 2026-08-19 23:31:15 +02:00
fluxer-ci[bot]andhampus-fluxer a078392888 chore(i18n): update public marketing catalogs (#1719)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-19 22:46:33 +02:00
Hampus 3060f23f8c chore(marketing): bump the marketing submodule pointer (#1766) 2026-08-19 22:29:10 +02:00
Hampus 6a5f0d4d29 fix(i18n): simplify the marketing translation and saved media strings (#1764) 2026-08-19 22:25:01 +02:00
Hampus 91d989dc7c fix(app): point the language notice at Weblate instead of email (#1763) 2026-08-19 22:05:39 +02:00
Hampus 7c82804df6 fix(app): stop sensitive content options overlapping in long locales (#1762) 2026-08-19 19:53:46 +02:00
Hampus b7de83f7fc fix(app): stop the Firefox microphone failure and self-mute storm (#1761) 2026-08-19 19:32:41 +02:00
Hampus 97bd022bee fix(app): render the discovery language icon at bold weight (#1760) 2026-08-19 19:08:48 +02:00
Hampus 62324df039 feat(app): add a language filter button to the discovery navbar (#1759) 2026-08-19 18:46:39 +02:00
Hampus 9f78b3d9a6 feat(app): retry failed image loads with escalating delay and connectivity waits (#1758) 2026-08-19 18:25:34 +02:00
Hampus 362a89f2b2 fix(app): keep embed icons mounted so they reserve space while loading (#1757) 2026-08-19 18:00:08 +02:00
Hampus ce41960fcd perf(app): load embed author and footer icons through the image cache (#1756) 2026-08-19 17:38:00 +02:00
Hampus 2083eaddd6 feat(app): wrap pasted links in every composer that allows masked links (#1755) 2026-08-19 17:19:39 +02:00
Hampus d398ebc44b test: drop desktop test files that no runner executes (#1753) 2026-08-19 14:58:59 +02:00
Hampus 59887ad404 fix(app): wrap pasted links from any source, not just Fluxer's own clipboard (#1752) 2026-08-19 14:57:13 +02:00
Hampus 5aa283e8e0 fix(desktop): repair a stale Linux .desktop entry instead of preserving it (#1751) 2026-08-19 14:56:29 +02:00
Hampus d9ed256a4b fix(desktop): pin Electron to 43.4.0 to restore KDE and XFCE tray icons (#1750) 2026-08-19 14:45:58 +02:00
Hampus a297f89b83 fix(markdown): parse a table that follows a text line without a blank line (#1749) 2026-08-19 14:24:33 +02:00
Hampus 4a16921242 fix(app): stop macOS window chrome from swallowing clicks below the titlebar (#1748) 2026-08-19 13:05:42 +02:00
Hampus a6f83c4fb1 feat(app): wrap the selection in a link when pasting a URL (#1747) 2026-08-19 12:55:15 +02:00
Hampus 7b5c82c6cf fix(app): keep quick switcher focus when results are recomputed (#1746) 2026-08-19 12:55:04 +02:00
Hampus 30a61ce90f chore(i18n): refresh catalogs and translate new strings (#1745) 2026-08-19 12:46:51 +02:00
Hampus 22bc2cab74 fix(app): offset toasts below the window chrome on macOS (#1744) 2026-08-19 12:42:08 +02:00
Hampus 53df9a0d6d fix(app): include remainder seconds in slowmode durations (#1743) 2026-08-19 12:41:56 +02:00
Hampus f9b7ec4d0b fix(app): stop the bio editor placeholder from overflowing (#1742) 2026-08-19 12:41:44 +02:00
Hampus 569abf57b7 fix(app): keep the edited marker on attachment-only messages (#1741) 2026-08-19 12:41:31 +02:00
Hampus ae8e32d809 fix(app): render modals above a fullscreen voice call (#1740) 2026-08-19 12:41:19 +02:00
Hampus a81b1abec7 fix(api): reject alt text edits on forwarded messages (#1739) 2026-08-19 12:41:07 +02:00
Hampus 8836565c32 fix(app): keep the guild verification timer stable across switches (#1738) 2026-08-19 12:40:55 +02:00
Hampus a1b595d52f fix(app): apply the current system theme when relaunching (#1737) 2026-08-19 12:40:43 +02:00
Hampus abb71ed558 fix(app): prompt for a restart when the system titlebar toggle changes (#1736) 2026-08-19 12:40:32 +02:00
Hampus c006d413ac fix(desktop): bump Electron to 43.4.1 to restore the Linux tray icon (#1735) 2026-08-19 12:40:20 +02:00
Hampus fa11acae15 fix(desktop): use the masked icon for AppImage desktop integration (#1734) 2026-08-19 12:40:09 +02:00
Hampus 300f467ad0 fix(desktop): stop the AppImage adding a duplicate menu entry each launch (#1733) 2026-08-19 12:39:57 +02:00
Hampus 698469fa96 perf(app): defer media capture routing and skip offscreen skeleton layout (#1732) 2026-08-19 02:58:01 +02:00
Hampus 591e9fe2ba fix(api): only finalize self-serve refunds once the provider confirms (#1731) 2026-08-19 02:57:58 +02:00
Hampus d148b4e5b7 feat(app): show 25 unread messages per channel in the inbox (#1729) 2026-08-19 01:06:00 +02:00
Hampus 324f333bb5 test: drop stale hosted instance config and voice engine boundary tests (#1728) 2026-08-19 00:48:06 +02:00
Hampus 9b0b703c9d fix(api): identify session clients correctly and attribute handoff sessions (#1727) 2026-08-19 00:41:29 +02:00
Hampus 5660972c71 perf(app): cut idle renderer CPU and unbounded memory growth (#1724) 2026-08-18 23:13:42 +02:00
Hampus b4a5eb77a8 docs: fix community health document links broken by the .github move (#1723) 2026-08-18 20:23:40 +02:00
Hampus 4bbfee4cec fix(app): make click-through the default and move animation pausing to Accessibility (#1722) 2026-08-18 19:40:35 +02:00
Hampus 9e89539f0a perf(app): stop unbounded WASM heap growth and idle-CPU churn (#1721) 2026-08-18 18:31:28 +02:00
fluxer-ci[bot]andhampus-fluxer fa71eb8682 chore(marketing): advance pointer 9720a17 → a31164c (#1717)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-18 16:50:27 +02:00
Hampus 49b7127bc7 chore(static): regenerate marketing screenshots and README showcase (#1718) 2026-08-18 16:50:23 +02:00
Hampus 9cb8812be0 fix(app-proxy): collapse the discovery cold-start retry condition (#1716) 2026-08-18 16:12:33 +02:00
Hampus 7d82d188a0 fix(gateway): register the session process with presence on resume (#1715) 2026-08-18 16:06:26 +02:00
Hampus 5ce5669f17 fix(app): allow the inbox shortcut while the composer is empty (#1714) 2026-08-18 14:31:23 +02:00
Hampus 9ea750152e fix(app-proxy): serve the SPA from cached discovery instead of blocking on refresh (#1713) 2026-08-18 06:07:53 +02:00
Hampus e87e2fe7bf fix(admin): show the deferred phone gate controls on hosted instances (#1712) 2026-08-18 05:49:33 +02:00
Hampus 871b5116dc fix(media-proxy): stop sending X-Robots-Tag in static mode (#1710) 2026-08-18 05:21:10 +02:00
Hampus d7b2e67c35 feat(api): defer registration phone verification to community joins (#1709) 2026-08-18 05:13:41 +02:00
Hampus 7e1ca9ed6a fix(api): mirror Stripe billing data using the shapes the pinned API version sends (#1708) 2026-08-18 02:35:58 +02:00
Hampus 1922d56188 fix(api): keep Stripe expand paths within the four-level limit (#1707) 2026-08-18 01:06:10 +02:00
Hampus cc105883a5 feat(app): restyle the inbox and message preview surfaces (#1706) 2026-08-18 00:43:18 +02:00
Hampus 41c7acdd8f feat(api): redirect artifact downloads to presigned storage URLs (#1703) 2026-08-17 21:47:46 +02:00
Hampus c35d29ea0b fix(app): resolve the unread anchor where channel message loads originate (#1701) 2026-08-17 16:20:49 +02:00
Hampus 97c29bf1fb fix(app): open channels at the unread boundary when the backlog exceeds one page (#1698) 2026-08-17 15:14:42 +02:00
Hampus 3ff7189566 fix(app): apply streamer mode to member nicknames and stop guild scope leaking into global surfaces (#1697) 2026-08-17 14:58:56 +02:00
Hampus 3fa766c39c fix(app): show the full user tag beside display names instead of a bare discriminator (#1696) 2026-08-17 14:46:06 +02:00
Hampus 10ae4bfe1e ci: publish releases with the Fluxer CI app instead of the Actions token (#1695) 2026-08-17 12:59:29 +02:00
Hampus d271f3112c fix(app): resend a stalled member list subscription before replacing the session (#1694) 2026-08-17 12:48:17 +02:00
Hampus 5a13172b46 ci: stop fork clones running repository-scoped workflows (#1693) 2026-08-17 12:06:24 +02:00
Hampus 2269e1899e fix(app): show friend nicknames on forward targets (#1692) 2026-08-17 12:00:08 +02:00
Hampus c61fd96df6 fix(app): search forward targets by username and every known nickname (#1691) 2026-08-17 11:51:07 +02:00
Hampus 6c68202222 fix(api): let SSO accounts without a password satisfy sudo verification (#1690) 2026-08-16 23:35:47 +02:00
Hampus e0bb10d5b7 fix(api): reject unclaimed guild creation before the email verification gate (#1689) 2026-08-16 23:34:07 +02:00
Hampus 96643ab20d fix(unfurl): key the cache on available provider credentials (#1688) 2026-08-16 23:19:51 +02:00
1152 changed files with 69038 additions and 78973 deletions

No files matched your search

+4 -4
View File
@@ -25,7 +25,7 @@ Closes #456
You must understand every line you submit and be able to explain why the change is correct.
The [LLM usage policy](LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
The [LLM usage policy](https://github.com/fluxerapp/fluxer/blob/main/.github/LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
Each contribution must contain one coherent change. Do not include unrelated fixes, refactoring or formatting changes.
@@ -80,15 +80,15 @@ Complete every section of the pull request template. Clearly describe:
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
Report security vulnerabilities privately through the channels specified in the [security policy](SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
## Private marketing project
+2 -2
View File
@@ -8,11 +8,11 @@ External contributions do not grant voting rights, commit access, employment or
## Licence and contributor rights
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). The licence permits its use, modification and redistribution subject to its terms.
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). The licence permits its use, modification and redistribution subject to its terms.
Fluxer Platform AB does not require contributors to sign a contributor licence agreement or assign their copyright. Contributors retain the copyright in their work.
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](https://github.com/fluxerapp/fluxer/blob/main/.github/CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
## Name and marks
+1 -1
View File
@@ -129,7 +129,7 @@ Deliberately submitting a fabricated security report MAY result in an immediate
Maintainers are not required to investigate possible LLM use proactively. Writing style alone is not evidence of a violation.
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](CODE_OF_CONDUCT.md).
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
## 11. Normative References
+20 -2
View File
@@ -65,10 +65,19 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: set variables
id: vars
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- resolve-calver
@@ -151,9 +160,18 @@ jobs:
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-admin
dockerfile: fluxer_admin/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-api
dockerfile: fluxer_api/Dockerfile
@@ -29,6 +29,7 @@ jobs:
build:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-app-proxy-self-hosted
dockerfile: fluxer_app_proxy/Dockerfile
+14 -5
View File
@@ -106,10 +106,10 @@ jobs:
- name: upload assets to S3 static bucket
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
STATIC_BUCKET: fluxer-static
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.STATIC_S3_ENDPOINT }}
STATIC_BUCKET: ${{ vars.STATIC_S3_BUCKET }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
--step upload_assets
@@ -187,9 +187,18 @@ jobs:
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
+28 -10
View File
@@ -67,10 +67,19 @@ jobs:
with:
toolchain: "1.93.0"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Set metadata
id: meta
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
@@ -130,10 +139,10 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
@@ -515,11 +524,11 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: https://ewr1.vultrobjects.com
S3_BUCKET: fluxer-downloads
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -581,9 +590,18 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub desktop release
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-docs
dockerfile: fluxer_docs/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-gateway
dockerfile: fluxer_gateway/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-gifs
dockerfile: fluxer_gifs/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-media-proxy
dockerfile: fluxer_media_proxy/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-messages
dockerfile: fluxer_messages/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-snowflakes
dockerfile: fluxer_snowflakes/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-static
dockerfile: fluxer_static/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-unfurl
dockerfile: fluxer_unfurl/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-users
dockerfile: fluxer_users/Dockerfile
@@ -17,6 +17,7 @@ concurrency:
jobs:
dispatch:
name: Dispatch regeneration
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-latest
steps:
- name: Create token
@@ -31,6 +31,7 @@ concurrency:
jobs:
metadata:
name: resolve exact private build metadata
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
@@ -39,11 +40,20 @@ jobs:
build_version: ${{ steps.inputs.outputs.build_version }}
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
steps:
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Resolve trusted build inputs
id: inputs
env:
EVENT_AFTER: ${{ github.event.after }}
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
PARENT_SHA: ${{ github.sha }}
PUBLIC_REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
+1
View File
@@ -5,6 +5,7 @@ permissions: {}
jobs:
label:
name: Label
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-latest
steps:
- name: Create token
@@ -41,7 +41,7 @@ concurrency:
jobs:
automatic:
name: Lock closed conversation
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
if: github.repository == 'fluxerapp/fluxer' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Create token
@@ -155,7 +155,7 @@ jobs:
retroactive:
name: Lock closed conversations retroactively
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
if: github.repository == 'fluxerapp/fluxer' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
runs-on: ubuntu-latest
steps:
- name: Create token
+2 -2
View File
@@ -89,7 +89,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: clippy, rustfmt
- name: Install pnpm
@@ -286,7 +286,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: rustfmt
- name: Sync ci helper dependencies
Generated
+10
View File
@@ -1777,6 +1777,7 @@ dependencies = [
"axum",
"base64",
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper-util",
@@ -1800,6 +1801,7 @@ dependencies = [
"anyhow",
"base64",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"moka",
"reqwest",
@@ -1836,6 +1838,7 @@ dependencies = [
"cc",
"clap",
"criterion",
"fluxer_common",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1875,6 +1878,7 @@ dependencies = [
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
"futures",
"hmac 0.13.0",
@@ -1943,6 +1947,7 @@ dependencies = [
"encoding_rs",
"entities",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"infer",
"moka",
@@ -2038,12 +2043,17 @@ dependencies = [
"aws-credential-types",
"aws-sigv4",
"axum",
"base64",
"hmac 0.13.0",
"maxminddb",
"moka",
"reqwest",
"serde_json",
"sha2 0.11.0",
"thiserror",
"time",
"tracing",
"url",
"urlencoding",
]
+1 -1
View File
@@ -31,5 +31,5 @@
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
</p>
+8 -1
View File
@@ -145,7 +145,14 @@
"!fluxer_static",
"!packages/fonts",
"!fluxer_admin/static/htmx.min.js",
"!fluxer_api/src/api/openapi/openapi.json"
"!fluxer_api/src/api/openapi/openapi.json",
"!fluxer_api/pkgs/email/src/email_i18n/locales",
"!fluxer_api/pkgs/email/src/email_i18n/weblate",
"!fluxer_api/src/api/content_i18n/locales",
"!fluxer_api/src/api/content_i18n/weblate",
"!packages/errors/src/i18n/locales",
"!packages/errors/src/i18n/weblate",
"!**/auto-i18n-reviewed-unchanged.json"
],
"ignoreUnknown": true
}
+34 -4
View File
@@ -12573,6 +12573,15 @@
"bluesky": {"type": "boolean"}
},
"required": ["gif", "youtube", "bluesky"]
},
"deferred_phone_gate": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"window_hours": {"type": "number"},
"member_threshold": {"type": "number"}
},
"required": ["enabled", "window_hours", "member_threshold"]
}
},
"required": [
@@ -12583,7 +12592,8 @@
"premium_mode",
"services",
"services_resolved",
"services_available"
"services_available",
"deferred_phone_gate"
]
},
"integrations": {
@@ -13110,6 +13120,21 @@
"youtube_enabled": {"nullable": true, "type": "boolean"},
"bluesky_enabled": {"nullable": true, "type": "boolean"}
}
},
"deferred_phone_gate": {
"nullable": true,
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"window_hours": {"type": "number", "maximum": 8760, "minimum": 0, "exclusiveMinimum": true},
"member_threshold": {
"type": "integer",
"maximum": 1000000,
"format": "int32",
"minimum": 0,
"exclusiveMinimum": true
}
}
}
}
}
@@ -13644,7 +13669,7 @@
"required": ["participants"]
},
"referenced_message": {
"description": "The message that this message is replying to or forwarding",
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
"nullable": true,
"type": "object",
"properties": {
@@ -14879,12 +14904,16 @@
"premium_grace_ends_at": {"nullable": true, "type": "string"},
"premium_lifetime_sequence": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"suspicious_activity_flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"},
"phone_verification_deferred": {
"type": "boolean",
"description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community"
},
"temp_banned_until": {"nullable": true, "type": "string"},
"pending_deletion_at": {"nullable": true, "type": "string"},
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"deletion_public_reason": {"nullable": true, "type": "string"},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 115},
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
"has_totp": {"type": "boolean"},
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
@@ -14919,6 +14948,7 @@
"premium_grace_ends_at",
"premium_lifetime_sequence",
"suspicious_activity_flags",
"phone_verification_deferred",
"temp_banned_until",
"pending_deletion_at",
"pending_bulk_message_deletion_at",
@@ -15451,7 +15481,7 @@
"acls": {
"type": "array",
"items": {"type": "string"},
"maxItems": 100,
"maxItems": 115,
"description": "List of access control permissions to assign"
}
},
+1
View File
@@ -69,6 +69,7 @@ mod tests {
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
+2
View File
@@ -102,6 +102,8 @@ pub struct AdminUser {
#[serde(default)]
pub suspicious_activity_flags: i32,
#[serde(default)]
pub phone_verification_deferred: bool,
#[serde(default)]
pub has_totp: bool,
#[serde(default)]
pub authenticator_types: Vec<i32>,
@@ -37,6 +37,28 @@ pub struct InstancePolicyResponse {
pub services_resolved: InstanceServicesResolved,
#[serde(default)]
pub services_available: InstanceServicesAvailable,
#[serde(default)]
pub deferred_phone_gate: DeferredPhoneGateResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeferredPhoneGateResponse {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub window_hours: f64,
#[serde(default)]
pub member_threshold: i64,
}
impl Default for DeferredPhoneGateResponse {
fn default() -> Self {
Self {
enabled: true,
window_hours: 6.0,
member_threshold: 50,
}
}
}
impl Default for InstancePolicyResponse {
@@ -50,6 +72,7 @@ impl Default for InstancePolicyResponse {
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
deferred_phone_gate: DeferredPhoneGateResponse::default(),
}
}
}
@@ -516,6 +539,18 @@ pub struct InstancePolicyUpdateRequest {
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DeferredPhoneGateUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_hours: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_threshold: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize)]
+1
View File
@@ -112,6 +112,7 @@ fn is_urlencoded_form(request: &Request) -> bool {
})
}
#[allow(clippy::result_large_err)]
async fn extract_csrf_from_form_body(
request: Request,
) -> Result<(Request, Option<String>), Response> {
+28 -1
View File
@@ -7,7 +7,7 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
@@ -547,6 +547,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
_ => None,
};
let services = build_services_update(form);
let deferred_phone_gate = build_deferred_phone_gate_update(form);
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
@@ -558,12 +559,37 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
direct_messages_disabled,
premium_mode,
services,
deferred_phone_gate,
}),
integrations: None,
media: None,
}
}
fn build_deferred_phone_gate_update(
form: &MultiValueForm,
) -> Option<DeferredPhoneGateUpdateRequest> {
let enabled = form
.first("policy_deferred_phone_gate_enabled")
.map(|value| value == "true");
let window_hours = form
.first("policy_deferred_phone_gate_window_hours")
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| *value > 0.0);
let member_threshold = form
.first("policy_deferred_phone_gate_member_threshold")
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() {
return None;
}
Some(DeferredPhoneGateUpdateRequest {
enabled,
window_hours,
member_threshold,
})
}
fn build_services_update(form: &MultiValueForm) -> Option<InstanceServicesUpdateRequest> {
let parse_tristate = |key: &str| match form.first(key) {
Some("inherit") => Some(None),
@@ -712,6 +738,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
direct_messages_disabled: None,
premium_mode: None,
services: None,
deferred_phone_gate: None,
}),
integrations: None,
media: None,
@@ -103,6 +103,7 @@ pub fn instance_config_page(
instance_config.self_hosted,
))
(sso_config_section(base, csrf_token, &instance_config.sso))
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
},
))
@if instance_config.self_hosted {
@@ -283,6 +284,57 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes
}
}
fn deferred_phone_gate_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
) -> Markup {
let gate = &policy.deferred_phone_gate;
let status = if gate.enabled {
("Enabled", BadgeVariant::Success)
} else {
("Disabled", BadgeVariant::Default)
};
html! {
div class="space-y-4 border-t border-neutral-200 pt-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Deferred phone verification" }
(badge(status.0, status.1))
}
p class="text-sm text-neutral-500" {
"When enabled, a phone requirement raised at registration is held back and only \
applied if the account joins a discoverable community, or one above the member \
threshold, within the window. Accounts that wait out the window are not challenged. \
Inbound-SMS requirements are never deferred."
}
form method="post" action={(base) "/instance-config?action=update_policy"} {
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[
("true", "Enabled"),
("false", "Disabled"),
], if gate.enabled { "true" } else { "false" }))
(text_input(
"policy_deferred_phone_gate_window_hours",
"Window (hours)",
&gate.window_hours.to_string(),
"6",
))
(text_input(
"policy_deferred_phone_gate_member_threshold",
"Member threshold",
&gate.member_threshold.to_string(),
"50",
))
(form_actions(html! {
(submit_button("Save deferred phone verification"))
}))
}
}
}
}
}
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
html! {
div class="space-y-4 border-t border-neutral-200 pt-6" {
@@ -291,6 +291,11 @@ fn flags_card(
can_update_suspicious,
Some(acl::USER_UPDATE_SUSPICIOUS_ACTIVITY),
))
@if user.phone_verification_deferred {
p class="text-sm text-amber-700 dark:text-amber-400" {
"Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window."
}
}
}
}
}
+1 -35
View File
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
}
}
#[tokio::test]
async fn hosted_instance_config_hides_self_host_setup_controls() {
let app = setup().await;
let body = get(&app, "/instance-config", &[]).await;
assert_full_layout(&body);
assert!(body.contains("Registration Controls"), "{body}");
assert!(body.contains("Runtime Integrations"), "{body}");
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
assert!(!body.contains("Public App Identity"), "{body}");
assert!(!body.contains("Setup complete"), "{body}");
assert!(!body.contains("Community & Policy"), "{body}");
assert!(!body.contains("Single community"), "{body}");
assert!(!body.contains("Direct messages &amp; friends"), "{body}");
assert!(!body.contains("Premium model"), "{body}");
assert!(!body.contains("Optional services"), "{body}");
assert!(!body.contains("Registration Fields"), "{body}");
assert!(
!body.contains("Collect date of birth during registration"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_app_public"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_app_registration"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_policy"),
"{body}"
);
}
#[tokio::test]
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
let app = setup().await;
@@ -898,6 +863,7 @@ fn user(id: &str, username: &str) -> Value {
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
@@ -25,6 +25,7 @@
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
@@ -26,6 +26,7 @@
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
@@ -26,6 +26,7 @@
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
@@ -29,12 +29,16 @@
"main": "./src/MediaProxyUtils.ts",
"types": "./src/MediaProxyUtils.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@types/node": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:"
}
}
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildExternalMediaProxyPath,
buildOpaqueExternalMediaProxyPath,
reconstructOriginalUrl,
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
import {describe, expect, it} from 'vitest';
const ROUND_TRIP_URLS = [
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
'https://example.com:8443/a.png',
'https://avatars.githubusercontent.com/u/241303489?v=4',
'http://example.com/plain.gif',
'https://example.com/deep/nested/path/to/file.jpeg',
'https://example.com/file.png?a=1&b=2&c=3',
'https://example.com/spaced%20name.png',
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
'https://sub.domain.example.co.uk/a/b.webp',
];
describe('buildExternalMediaProxyPath', () => {
it('emits the plain path shape with the extension last', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
);
});
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
);
});
it('keeps a non default port on the host segment', () => {
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
});
it('preserves the http scheme', () => {
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
});
it('handles a root url with no path', () => {
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
});
it('never emits the v2 prefix any more', () => {
for (const url of ROUND_TRIP_URLS) {
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
}
});
it('ends in the source file extension so extension based cdn caching applies', () => {
for (const [url, ext] of [
['https://example.com/a.webp', '.webp'],
['https://example.com/a.png?x=1', '.png'],
['https://example.com/a/b/c.jpeg', '.jpeg'],
] as const) {
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
}
});
it('rejects a url it cannot parse', () => {
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
});
});
describe('reconstructOriginalUrl', () => {
it('round trips every supported shape', () => {
for (const url of ROUND_TRIP_URLS) {
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
}
});
it('decodes an externally produced path verbatim', () => {
expect(
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
});
it('does not double the question mark when the query segment carries one', () => {
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
expect(decoded).toBe('https://example.com/x.png?a=1');
expect(decoded).not.toContain('??');
});
it('still accepts a query segment without a leading question mark', () => {
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
});
it('still decodes v2 paths so links already sent keep working', () => {
expect(reconstructOriginalUrl(buildOpaqueExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
'https://example.com/a.png?x=1',
);
});
it('rejects a path that has no host after the protocol', () => {
expect(() => reconstructOriginalUrl('https')).toThrow();
});
it('rejects an empty v2 payload', () => {
expect(() => reconstructOriginalUrl('v2/')).toThrow();
});
});
@@ -2,21 +2,21 @@
const BASE64_URL_PADDING_REGEX = /=*$/;
const LEGACY_PROTOCOL_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*$/;
const V2_PATH_PREFIX = 'v2/';
const OPAQUE_PATH_PREFIX = 'v2/';
function encodeV2PathComponent(value: string): string {
function encodeOpaquePathComponent(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url').replace(BASE64_URL_PADDING_REGEX, '');
}
function decodeV2PathComponent(value: string): string {
function decodeOpaquePathComponent(value: string): string {
return Buffer.from(value, 'base64url').toString('utf8');
}
function decodeLegacyComponent(component: string): string {
function decodeSegmentedComponent(component: string): string {
return decodeURIComponent(component);
}
function getLegacyProtocolIndex(parts: Array<string>): number {
function getSegmentedProtocolIndex(parts: Array<string>): number {
const firstPart = parts[0];
if (firstPart && LEGACY_PROTOCOL_REGEX.test(firstPart)) {
return 0;
@@ -33,15 +33,15 @@ function getLegacyProtocolIndex(parts: Array<string>): number {
throw new Error('Protocol is missing in the proxy URL path.');
}
interface LegacyHostAndPort {
interface SegmentedHostAndPort {
hostname: string;
port: string;
}
function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
function decodeSegmentedHostAndPort(hostPart: string): SegmentedHostAndPort {
const separatorIndex = hostPart.lastIndexOf(':');
if (separatorIndex === -1) {
const hostname = decodeLegacyComponent(hostPart);
const hostname = decodeSegmentedComponent(hostPart);
if (!hostname) {
throw new Error('Hostname is invalid in the proxy URL path.');
}
@@ -53,14 +53,14 @@ function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
throw new Error('Hostname is invalid in the proxy URL path.');
}
return {
hostname: decodeLegacyComponent(encodedHostname),
port: encodedPort ? decodeLegacyComponent(encodedPort) : '',
hostname: decodeSegmentedComponent(encodedHostname),
port: encodedPort ? decodeSegmentedComponent(encodedPort) : '',
};
}
function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
function reconstructSegmentedOriginalUrl(proxyUrlPath: string): string {
const parts = proxyUrlPath.split('/');
const protocolIndex = getLegacyProtocolIndex(parts);
const protocolIndex = getSegmentedProtocolIndex(parts);
const protocol = parts[protocolIndex];
if (!protocol) {
throw new Error('Protocol is missing in the proxy URL path.');
@@ -71,29 +71,47 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
}
const encodedQuery = parts.slice(0, protocolIndex).join('/');
const encodedPath = parts.slice(protocolIndex + 2).join('/');
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
const path = decodeLegacyComponent(encodedPath);
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
const query = encodedQuery ? decodeSegmentedComponent(encodedQuery) : '';
const path = decodeSegmentedComponent(encodedPath);
const {hostname, port} = decodeSegmentedHostAndPort(hostPart);
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
}
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
const encodedOriginalUrl = proxyUrlPath.slice(V2_PATH_PREFIX.length);
function reconstructOpaqueOriginalUrl(proxyUrlPath: string): string {
const encodedOriginalUrl = proxyUrlPath.slice(OPAQUE_PATH_PREFIX.length);
if (!encodedOriginalUrl) {
throw new Error('Encoded URL is missing in the proxy URL path.');
}
return decodeV2PathComponent(encodedOriginalUrl);
return decodeOpaquePathComponent(encodedOriginalUrl);
}
export function buildOpaqueExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
return `${OPAQUE_PATH_PREFIX}${encodeOpaquePathComponent(parsedUrl.toString())}`;
}
export function buildExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
const protocol = parsedUrl.protocol.replace(/:$/u, '');
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
const path = parsedUrl.pathname
.replace(/^\//u, '')
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
segments.push(protocol, host);
if (path) {
segments.push(path);
}
return segments.join('/');
}
export function reconstructOriginalUrl(proxyUrlPath: string): string {
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
? reconstructV2OriginalUrl(proxyUrlPath)
: reconstructLegacyOriginalUrl(proxyUrlPath);
const reconstructedUrl = proxyUrlPath.startsWith(OPAQUE_PATH_PREFIX)
? reconstructOpaqueOriginalUrl(proxyUrlPath)
: reconstructSegmentedOriginalUrl(proxyUrlPath);
new URL(reconstructedUrl);
return reconstructedUrl;
}
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
}
},
"include": ["src/**/*"]
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+12
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -429,6 +431,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
testHarnessToken: master.dev.test_harness_token,
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
@@ -460,6 +464,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
voiceReconciliation: {
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
},
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
@@ -742,6 +742,9 @@ class BillingAdminControllerService {
refundTarget,
subscription,
});
if (refundDecision.amountCents !== null && !refundTarget) {
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
}
const intentId = await this.billingRepository.actionIntents.create({
userId: BigInt(syncedTargetUser.id),
actorAdminId: BigInt(params.adminUserId),
@@ -758,10 +761,7 @@ class BillingAdminControllerService {
await this.billingRepository.actionIntents.markStage(intentId, 'sub_canceled', {
sub_canceled_at: new Date(),
});
if (refundDecision.amountCents !== null) {
if (!refundTarget) {
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
}
if (refundDecision.amountCents !== null && refundTarget) {
refund = await this.stripe.refunds.create(
{
...(refundTarget.paymentIntentId
@@ -100,6 +100,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
youtube_enabled: policy.youtube_enabled,
bluesky_enabled: policy.bluesky_enabled,
},
deferred_phone_gate: {
enabled: policy.deferred_phone_gate_enabled,
window_hours: policy.deferred_phone_gate_window_hours,
member_threshold: policy.deferred_phone_gate_member_threshold,
},
services_resolved: resolvedServices,
services_available: {
gif: integrations.gif.effective_available,
@@ -591,6 +596,17 @@ async function applyInstancePolicyUpdate(
patch.bluesky_enabled = policy.services.bluesky_enabled ?? null;
}
}
if (policy.deferred_phone_gate) {
if (policy.deferred_phone_gate.enabled !== undefined) {
patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled;
}
if (policy.deferred_phone_gate.window_hours !== undefined) {
patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours;
}
if (policy.deferred_phone_gate.member_threshold !== undefined) {
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
}
}
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.setInstancePolicyConfig(patch);
}
+4 -1
View File
@@ -2,6 +2,7 @@
import dns from 'node:dns';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN} from '@fluxer/constants/src/UserConstants';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
@@ -82,7 +83,9 @@ export async function mapUserToAdminResponse(
premium_grace_ends_at: user.premiumGraceEndsAt?.toISOString() ?? null,
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
suspicious_activity_flags: user.suspiciousActivityFlags,
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
temp_banned_until:
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
deletion_reason_code: user.deletionReasonCode,
@@ -2,7 +2,15 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {
ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS,
ALL_SUSPICIOUS_ACTIVITY_FLAGS,
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
imposePhoneRequirements,
SuspiciousActivityFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -31,11 +39,13 @@ import * as AuthMfa from '../../auth/AuthMfa';
import * as AuthSession from '../../auth/AuthSession';
import * as AuthUtility from '../../auth/AuthUtility';
import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes';
import type {UserRow} from '../../database/types/UserTypes';
import {Logger} from '../../Logger';
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {mapUserToAdminResponse} from '../models/UserTypes';
import type {AdminAuditService} from './AdminAuditService';
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
@@ -406,11 +416,14 @@ export class AdminUserSecurityService {
if (!user) {
throw new UnknownUserError();
}
const updatedUser = await userRepository.patchUpsert(
userId,
{has_verified_phone: data.has_verified_phone},
user.toRow(),
);
const phonePatch: Partial<UserRow> = {has_verified_phone: data.has_verified_phone};
if (data.has_verified_phone) {
const clearedFlags = (user.suspiciousActivityFlags ?? 0) & ~ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS;
if (clearedFlags !== (user.suspiciousActivityFlags ?? 0)) {
phonePatch.suspicious_activity_flags = clearedFlags;
}
}
const updatedUser = await userRepository.patchUpsert(userId, phonePatch, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
await auditService.createAuditLog({
adminUserId,
@@ -418,7 +431,15 @@ export class AdminUserSecurityService {
targetId: BigInt(userId),
action: 'update_has_verified_phone',
auditLogReason,
metadata: new Map([['has_verified_phone', String(data.has_verified_phone)]]),
metadata: new Map(
phonePatch.suspicious_activity_flags === undefined
? [['has_verified_phone', String(data.has_verified_phone)]]
: [
['has_verified_phone', String(data.has_verified_phone)],
['suspicious_activity_flags_before', String(user.suspiciousActivityFlags ?? 0)],
['suspicious_activity_flags_after', String(phonePatch.suspicious_activity_flags)],
],
),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
@@ -438,15 +459,24 @@ export class AdminUserSecurityService {
if (!user) {
throw new UnknownUserError();
}
const currentFlags = user.suspiciousActivityFlags ?? 0;
const keepsDeferral =
(currentFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0 &&
(data.flags & DEFERRABLE_PHONE_FLAGS) !== 0 &&
(data.flags & DEFERRABLE_PHONE_FLAGS) === (currentFlags & DEFERRABLE_PHONE_FLAGS);
const newFlags = keepsDeferral ? data.flags | DEFERRED_PHONE_ON_COMMUNITY_JOIN : data.flags;
const updatedUser = await userRepository.patchUpsert(
userId,
{
suspicious_activity_flags: data.flags,
suspicious_activity_flags: newFlags,
},
user.toRow(),
);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
if ((user.suspiciousActivityFlags ?? 0) !== data.flags && data.flags !== 0) {
if (
(currentFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) &&
(newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== 0
) {
await this.recordRiskOutcomes(userId, ['challenged'], 'admin_update_suspicious_activity_flags');
}
await auditService.createAuditLog({
@@ -600,7 +630,7 @@ export class AdminUserSecurityService {
throw new UnknownUserError();
}
const currentFlags = user.suspiciousActivityFlags ?? 0;
const newFlags = (currentFlags | addMask) & ~removeMask;
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
const updatedUser = await userRepository.patchUpsert(
userId,
{suspicious_activity_flags: newFlags},
@@ -720,7 +750,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: Date;
clientIp: string;
clientUserAgent: string | null;
clientIsDesktop: boolean | null;
clientOs: string | null;
deletedAt: Date | null;
}> = [
...activeSessions.map((s) => ({
@@ -729,7 +759,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: s.approximateLastUsedAt,
clientIp: s.clientIp,
clientUserAgent: s.clientUserAgent,
clientIsDesktop: s.clientIsDesktop,
clientOs: s.clientOs ?? null,
deletedAt: null as Date | null,
})),
...tombstones.map((t) => ({
@@ -738,7 +768,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: t.approximateLastUsedAt,
clientIp: t.clientIp,
clientUserAgent: t.clientUserAgent,
clientIsDesktop: t.clientIsDesktop,
clientOs: t.clientOs ?? null,
deletedAt: t.deletedAt,
})),
];
@@ -747,6 +777,8 @@ export class AdminUserSecurityService {
if (a.deletedAt !== null && b.deletedAt === null) return 1;
return b.createdAt.getTime() - a.createdAt.getTime();
});
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
const productName = branding.product_name;
const canViewIp = acls.has(AdminACLs.USER_VIEW_IP) || acls.has(AdminACLs.WILDCARD);
if (!canViewIp) {
await auditService.createAuditLog({
@@ -759,9 +791,10 @@ export class AdminUserSecurityService {
});
return {
sessions: entries.map((entry) => {
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: entry.clientUserAgent,
isDesktopClient: entry.clientIsDesktop,
reportedOs: entry.clientOs,
productName,
});
return {
session_id_hash: entry.sessionIdHash.toString('base64url'),
@@ -769,8 +802,8 @@ export class AdminUserSecurityService {
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
client_ip: '[redacted]',
client_ip_reverse: null,
client_os: clientOs,
client_platform: clientPlatform,
client_os: clientInfo.os,
client_platform: clientInfo.platform,
client_location: null,
deleted_at: entry.deletedAt?.toISOString() ?? null,
};
@@ -807,9 +840,10 @@ export class AdminUserSecurityService {
const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null;
const reverseDnsResult = reverseDnsResults[index];
const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null;
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: entry.clientUserAgent,
isDesktopClient: entry.clientIsDesktop,
reportedOs: entry.clientOs,
productName,
});
return {
session_id_hash: entry.sessionIdHash.toString('base64url'),
@@ -817,8 +851,8 @@ export class AdminUserSecurityService {
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
client_ip: entry.clientIp,
client_ip_reverse: clientIpReverse,
client_os: clientOs,
client_platform: clientPlatform,
client_os: clientInfo.os,
client_platform: clientInfo.platform,
client_location: clientLocation,
deleted_at: entry.deletedAt?.toISOString() ?? null,
};
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
ListGuildMembersRequest,
@@ -18,6 +19,8 @@ import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {mapGuildsToAdminResponse} from '../../models/GuildTypes';
const ADMIN_STICKER_MEDIA_RUNG: MediaProxyImageSize = 320;
interface AdminGuildLookupServiceDeps {
guildRepository: IGuildRepositoryAggregate;
userRepository: IUserRepository;
@@ -201,6 +204,6 @@ export class AdminGuildLookupService {
}
private buildStickerMediaUrl(id: string, animated: boolean): string {
return `${Config.endpoints.media}/stickers/${id}.webp?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}${animated ? '&animated=true' : ''}`;
return `${Config.endpoints.media}/stickers/${id}.webp?size=${ADMIN_STICKER_MEDIA_RUNG}${animated ? '&animated=true' : ''}`;
}
}
@@ -43,6 +43,7 @@ export class AdminGuildMembershipService {
throw new UnknownUserError();
}
await guildService.members.addUserToGuild({
skipRiskGate: true,
userId,
guildId,
sendJoinMessage: true,
@@ -83,6 +84,7 @@ export class AdminGuildMembershipService {
try {
const userId = createUserID(userIdBigInt);
await guildService.members.addUserToGuild({
skipRiskGate: true,
userId,
guildId,
sendJoinMessage: false,
@@ -283,7 +283,7 @@ describe('Admin billing overview', () => {
invoices: {
[params.invoiceId]: {
customer: params.stripeCustomerId,
subscription: params.stripeSubscriptionId,
subscriptionId: params.stripeSubscriptionId,
amount_due: params.amountPaidCents,
amount_paid: params.amountPaidCents,
billing_reason: 'subscription_cycle',
@@ -359,7 +359,7 @@ describe('Admin billing overview', () => {
invoices: {
in_local_checkout_1: {
customer: stripeCustomerId,
subscription: 'sub_billing_target',
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
@@ -396,7 +396,7 @@ describe('Admin billing overview', () => {
},
in_renewal_1: {
customer: stripeCustomerId,
subscription: 'sub_billing_target',
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_cycle',
@@ -545,7 +545,7 @@ describe('Admin billing overview', () => {
invoices: {
[invoiceId]: {
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
subscriptionId: stripeSubscriptionId,
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
+1 -13
View File
@@ -551,18 +551,7 @@ export function AuthController(app: HonoApp) {
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientPlatform = ctx.req.header('x-fluxer-platform')?.trim().toLowerCase() ?? undefined;
return ctx.json(
await ctx.get('authRequestService').initiateHandoff({
userAgent: ctx.req.header('User-Agent'),
clientIp,
clientPlatform,
}),
);
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
},
);
app.get(
@@ -611,7 +600,6 @@ export function AuthController(app: HonoApp) {
});
await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
request: ctx.req.raw,
clientIp,
authToken: ctx.get('authToken') ?? undefined,
});
+4 -1
View File
@@ -100,7 +100,10 @@ export async function revertEmailChange(
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
const [authToken] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
const [authToken] = await AuthSession.createAuthSession(ctx, {
user: updatedUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
await contactChangeLog.recordDiff({
oldUser: user,
newUser: updatedUser,
+21 -29
View File
@@ -105,9 +105,7 @@ export interface IpAuthorizationTicketCache {
userId: string;
email: string;
username: string;
clientIp: string;
userAgent: string;
platform: string | null;
origin: AuthSession.SessionOrigin;
authToken: string;
clientLocation: string;
inviteCode?: string | null;
@@ -115,8 +113,8 @@ export interface IpAuthorizationTicketCache {
createdAt: number;
}
function getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket:${ticket}`;
export function getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket-v2:${ticket}`;
}
function getTokenCacheKey(token: string): string {
@@ -148,7 +146,7 @@ export async function resendIpAuthorization(
payload.email,
payload.username,
payload.authToken,
payload.clientIp,
payload.origin.ip,
payload.clientLocation,
null,
);
@@ -172,7 +170,7 @@ export async function completeIpAuthorization(
user_id: string;
ticket: string;
}> {
const {users, cache, config} = ctx.services;
const {users, cache} = ctx.services;
const tokenMapping = await cache.get<{
ticket: string;
}>(getTokenCacheKey(token));
@@ -193,19 +191,8 @@ export async function completeIpAuthorization(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await users.createAuthorizedIp(user.id, payload.clientIp);
const headers: Record<string, string> = {
[config.proxy.client_ip_header]: payload.clientIp,
'user-agent': payload.userAgent,
};
if (payload.platform) {
headers['x-fluxer-platform'] = payload.platform;
}
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
headers,
method: 'POST',
});
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, request: syntheticRequest});
await users.createAuthorizedIp(user.id, payload.origin.ip);
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin});
await cache.delete(cacheKey);
await cache.delete(getTokenCacheKey(token));
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
@@ -313,15 +300,11 @@ export async function login(
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
const geoipResult = await lookupGeoip(clientIp);
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
const userAgent = request.headers.get('user-agent') || '';
const platform = request.headers.get('x-fluxer-platform');
const cachePayload: IpAuthorizationTicketCache = {
userId: currentUser.id.toString(),
email: currentUser.email!,
username: currentUser.username,
clientIp,
userAgent,
platform: platform ?? null,
origin: AuthSession.resolveSessionOrigin(ctx, request),
authToken,
clientLocation,
inviteCode: data.invite_code ?? null,
@@ -329,7 +312,7 @@ export async function login(
createdAt: Date.now(),
};
const ttlSeconds = seconds('15 minutes');
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await cache.set<IpAuthorizationTicketCache>(getTicketCacheKey(ticket), cachePayload, ttlSeconds);
await cache.set<{
ticket: string;
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
@@ -364,7 +347,10 @@ export async function login(
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
}
}
const [token] = await AuthSession.createAuthSession(ctx, {user: currentUser, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user: currentUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {
user_id: currentUser.id.toString(),
token,
@@ -418,7 +404,10 @@ export async function loginMfaTotp(
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: user.id.toString(), token};
}
@@ -438,7 +427,10 @@ export async function loginMfaWebAuthn(
AuthUtility.assertNonBotUser(ctx, user);
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: user.id.toString(), token};
}
+12 -17
View File
@@ -5,9 +5,10 @@ import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSche
import {uint8ArrayToBase64} from 'uint8array-extras';
import {Config} from '../Config';
import {Logger} from '../Logger';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {AuthSession} from '../models/AuthSession';
import {getLocationLabelFromIp} from '../utils/IpUtils';
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
const DEV_FALLBACK_AUTH_SESSION_LOCATION = 'Stockholm, Stockholm County, Sweden';
@@ -40,30 +41,24 @@ export async function mapAuthSessionsToResponse({
const locationResults = await Promise.allSettled(
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
);
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
return sortedSessions.map((authSession, index): AuthSessionResponse => {
const locationResult = locationResults[index];
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
let clientOs: string;
let clientPlatform: string;
if (authSession.clientUserAgent) {
const parsed = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
isDesktopClient: authSession.clientIsDesktop,
});
clientOs = parsed.clientOs;
clientPlatform = parsed.clientPlatform;
} else {
clientOs = authSession.clientOs || 'Unknown';
clientPlatform = authSession.clientPlatform || 'Unknown';
}
const clientInfo = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
reportedOs: authSession.clientOs ?? null,
productName: branding.product_name,
});
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
return {
id_hash: idHash,
client_info: {
platform: clientPlatform,
os: clientOs,
browser: undefined,
platform: clientInfo.platform,
os: clientInfo.os,
browser: clientInfo.browser,
device: clientInfo.device,
location: clientLocation
? {
city: clientLocation.split(',').at(0)?.trim() || null,
+4 -1
View File
@@ -275,7 +275,10 @@ export async function resetPassword(
if (hasMfa) {
return await createMfaTicketResponse(ctx, updatedUser);
}
const [token] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user: updatedUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: updatedUser.id.toString(), token};
}
+6 -2
View File
@@ -39,6 +39,7 @@ import {
normalizePolicyContactDomain,
} from '../risk/AccountPolicyEvaluator';
import type {IRegistrationEventsRepository} from '../risk/adapters/VelocityAdapter';
import {deferPhoneFlagsUntilCommunityJoin} from '../risk/DeferredPhoneGate';
import type {IRiskHistoryRepository} from '../risk/HistoricalOutcomeRepository';
import type {IRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
import {deriveLatestRiskContext} from '../risk/RiskHistoryContext';
@@ -334,7 +335,7 @@ export async function register(
action: riskResult.recommendedAction,
},
});
const combinedFlags = policyDecision.flagBits;
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
const createdAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: userId.toString(),
@@ -443,7 +444,10 @@ export async function register(
);
}
await singleCommunityService.joinStockCommunity(userId, requestCache);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
if (grantBootstrapAdmin) {
await instanceConfigRepository.markAdminBootstrapped();
}
+24 -23
View File
@@ -33,11 +33,12 @@ import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResp
import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {User} from '../models/User';
import {mapUserToPartialResponse} from '../user/UserMappers';
import {lookupGeoip} from '../utils/IpUtils';
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
import {generateUsernameSuggestions} from '../utils/UsernameSuggestionUtils';
import * as AuthEmail from './AuthEmail';
import * as AuthEmailRevert from './AuthEmailRevert';
@@ -89,7 +90,6 @@ interface AuthLogoutRequest {
interface AuthHandoffCompleteRequest {
data: HandoffCompleteRequest;
request: Request;
clientIp: string;
authToken?: string;
}
@@ -122,9 +122,7 @@ interface AuthLogoutAuthSessionsRequest {
}
interface AuthHandoffInitiateRequest {
userAgent?: string;
clientIp: string;
clientPlatform?: string;
request: Request;
}
interface AuthHandoffInfoRequest {
@@ -263,7 +261,7 @@ export class AuthRequestService {
user: await this.getUserPartial(parsed.user_id),
};
}
const ticketPayload = await cache.get(`ip-auth-ticket:${ticket}`);
const ticketPayload = await cache.get(AuthLogin.getTicketCacheKey(ticket));
if (!ticketPayload) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
}
@@ -276,7 +274,10 @@ export class AuthRequestService {
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
const [token] = await AuthSession.createAuthSession(this.apiContext, {
user,
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
});
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
}
@@ -298,12 +299,9 @@ export class AuthRequestService {
return {suggestions: generateUsernameSuggestions(globalName)};
}
async initiateHandoff({
userAgent,
clientIp,
clientPlatform,
}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform});
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({origin});
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
@@ -312,19 +310,22 @@ export class AuthRequestService {
async getHandoffInfo({code, clientIp}: AuthHandoffInfoRequest): Promise<HandoffInfoResponse> {
const info = await this.desktopHandoffService.getHandoffInfo(code, clientIp);
if (info.status === 'expired' || !info.clientIp) {
if (info.status === 'expired' || !info.origin) {
return {status: info.status, client_info: null};
}
const geo = await lookupGeoip(info.clientIp);
const {clientOs, clientPlatform} = resolveSessionClientInfo({
userAgent: info.userAgent ?? null,
isDesktopClient: info.clientPlatform === 'desktop',
const geo = await lookupGeoip(info.origin.ip);
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
const resolved = resolveSessionClientInfo({
userAgent: info.origin.userAgent,
reportedOs: info.origin.clientOs,
productName: branding.product_name,
});
return {
status: 'pending',
client_info: {
platform: clientPlatform,
os: clientOs,
platform: resolved.platform,
os: resolved.os,
device: resolved.device,
location: {
city: geo.city,
region: geo.region,
@@ -334,18 +335,18 @@ export class AuthRequestService {
};
}
async completeHandoff({data, request, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
const sessionToken = data.token ?? authToken;
if (!sessionToken) {
throw new UnauthorizedError();
}
await this.desktopHandoffService.completeHandoff(
data.code,
() =>
(origin) =>
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
token: sessionToken,
expectedUserId: data.user_id,
request,
origin,
}),
clientIp,
);
+31 -20
View File
@@ -15,12 +15,19 @@ import {Logger} from '../Logger';
import type {AuthSession} from '../models/AuthSession';
import type {User} from '../models/User';
import {lookupGeoip} from '../utils/IpUtils';
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
import {mapAuthSessionsToResponse} from './AuthModel';
import * as AuthUtility from './AuthUtility';
export interface SessionOrigin {
ip: string;
userAgent: string | null;
clientOs: string | null;
}
interface CreateAuthSessionParams {
user: User;
request: Request;
origin: SessionOrigin;
}
interface LogoutAuthSessionsParams {
@@ -60,29 +67,35 @@ interface ReplaceCurrentAuthSessionResult {
interface CreateAdditionalAuthSessionFromTokenParams {
token: string;
expectedUserId?: string;
request: Request;
origin: SessionOrigin;
}
export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin {
const {config} = ctx.services;
const ip = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const userAgent = request.headers.get('user-agent')?.trim() || null;
const clientOs = isFluxerNativeUserAgent(userAgent)
? parseReportedClientOs(request.headers.get('x-fluxer-client-properties'))
: null;
return {ip, userAgent, clientOs};
}
export async function createAuthSession(
ctx: ApiContext,
{user, request}: CreateAuthSessionParams,
{user, origin}: CreateAuthSessionParams,
): Promise<[token: string, AuthSession]> {
const {users, config} = ctx.services;
const {users} = ctx.services;
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
const ip = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
const uaRaw = request.headers.get('user-agent') ?? '';
const isDesktopClient = platformHeader === 'desktop';
let clientCountry: string | null = null;
try {
const geoip = await lookupGeoip(ip);
const geoip = await lookupGeoip(origin.ip);
clientCountry = geoip.countryCode ? geoip.countryCode.toUpperCase() : null;
} catch (error) {
Logger.warn({userId: user.id.toString(), error}, 'GeoIP lookup failed at session creation');
@@ -92,11 +105,9 @@ export async function createAuthSession(
session_id_hash: Buffer.from(AuthUtility.getTokenIdHash(ctx, token)),
created_at: now,
approx_last_used_at: now,
client_ip: ip,
client_user_agent: uaRaw || null,
client_is_desktop: isDesktopClient,
client_os: null,
client_platform: null,
client_ip: origin.ip,
client_user_agent: origin.userAgent,
client_os: origin.clientOs,
client_country: clientCountry,
version: 1,
});
@@ -105,7 +116,7 @@ export async function createAuthSession(
export async function createAdditionalAuthSessionFromToken(
ctx: ApiContext,
{token, expectedUserId, request}: CreateAdditionalAuthSessionFromTokenParams,
{token, expectedUserId, origin}: CreateAdditionalAuthSessionFromTokenParams,
): Promise<{
token: string;
userId: string;
@@ -122,7 +133,7 @@ export async function createAdditionalAuthSessionFromToken(
if (expectedUserId && user.id.toString() !== expectedUserId) {
throw new SessionTokenMismatchError();
}
const [newToken] = await createAuthSession(ctx, {user, request});
const [newToken] = await createAuthSession(ctx, {user, origin});
return {token: newToken, userId: user.id.toString()};
}
@@ -205,7 +216,7 @@ export async function replaceCurrentAuthSession(
(authSession) => !authSession.sessionIdHash.equals(currentAuthSession.sessionIdHash),
);
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, request});
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
await dispatchAuthSessionChange(ctx, {
userId: user.id,
@@ -5,8 +5,9 @@ import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCo
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import type {SessionOrigin} from '../AuthSession';
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 12;
@@ -19,9 +20,7 @@ const MAX_INFO_LOOKUPS = 3;
interface HandoffData {
createdAt: number;
userAgent?: string;
clientIp: string;
clientPlatform?: string;
origin: SessionOrigin;
infoLookupCount: number;
}
@@ -61,7 +60,7 @@ function assertValidHandoffCode(code: string): void {
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {userAgent?: string; clientIp: string; clientPlatform?: string}): Promise<{
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
code: string;
expiresAt: Date;
}> {
@@ -70,9 +69,7 @@ export class DesktopHandoffService {
const normalizedCode = normalizeHandoffCode(code);
const handoffData: HandoffData = {
createdAt: Date.now(),
userAgent: args.userAgent,
clientIp: args.clientIp,
clientPlatform: args.clientPlatform,
origin: args.origin,
infoLookupCount: 0,
};
const expirySeconds = seconds('5 minutes');
@@ -83,7 +80,7 @@ export class DesktopHandoffService {
async completeHandoff(
code: string,
createTokenData: () => Promise<{token: string; userId: string}>,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<void> {
const {cache} = this.apiContext.services;
@@ -107,7 +104,7 @@ export class DesktopHandoffService {
if (remainingSeconds <= 0) {
throw new HandoffCodeExpiredError();
}
const {token, userId} = await createTokenData();
const {token, userId} = await createTokenData(handoffData.origin);
const tokenData: HandoffTokenData = {
token,
userId,
@@ -122,9 +119,7 @@ export class DesktopHandoffService {
approverIp: string,
): Promise<{
status: 'pending' | 'expired';
userAgent?: string;
clientIp?: string;
clientPlatform?: string;
origin?: SessionOrigin;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
@@ -149,12 +144,7 @@ export class DesktopHandoffService {
{approvedAt: Date.now()},
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
);
return {
status: 'pending',
userAgent: handoffData.userAgent,
clientIp: handoffData.clientIp,
clientPlatform: handoffData.clientPlatform,
};
return {status: 'pending', origin: handoffData.origin};
}
async getHandoffStatus(
@@ -332,7 +332,10 @@ export class SsoService {
});
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
const user = await this.resolveUserFromClaims(claims, config);
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
const [token] = await AuthSession.createAuthSession(this.apiContext, {
user,
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
});
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
}
@@ -31,6 +31,16 @@ export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boo
);
}
export function hasNoVerifiableCredential(
user: {passwordHash: string | null; isBot: boolean},
hasMfa: boolean,
): boolean {
if (user.isBot || hasMfa) {
return false;
}
return user.passwordHash === null;
}
export function deriveSudoMethods(user: {
totpSecret?: string | null;
authenticatorTypes?: Set<number> | null;
@@ -86,8 +96,7 @@ async function verifySudoMode(
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
return {verified: true, sudoToken, method: 'mfa'};
}
const isUnclaimedAccount = user.isUnclaimedAccount();
if (isUnclaimedAccount && !hasMfa) {
if (hasNoVerifiableCredential(user, hasMfa)) {
return {verified: true, method: 'password'};
}
if (body.password && !hasMfa) {
@@ -0,0 +1,211 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {setInjectedRegistrationRiskEvaluator} from '../../middleware/ServiceMiddleware';
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import {
RecommendedAction,
RiskConfidence,
RiskDecisionMethod,
RiskLevel,
type RiskLevel as RiskLevelType,
} from '../../risk/RiskTypes';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import type {IRegistrationRiskEvaluator} from '../services/IRegistrationRiskEvaluator';
import {
createAuthHarness,
createTestAccount,
createUniqueEmail,
createUniqueUsername,
loginAccount,
registerUser,
} from './AuthTestUtils';
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
return {
async evaluate() {
return {
level,
recommendedAction: RecommendedAction.RequireOutboundPhone,
assessment: {
suspicious: true,
level,
confidence: RiskConfidence.High,
riskScore,
reasoning: 'deferred phone gate test',
recommendedAction: RecommendedAction.RequireOutboundPhone,
method: RiskDecisionMethod.Noop,
modelUsed: 'test',
rounds: 0,
elapsedMs: 0,
signals: {},
},
};
},
};
}
async function createGuildWithInvite(harness: ApiTestHarness): Promise<{guildId: string; inviteCode: string}> {
let owner = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${owner.userId}/acls`)
.body({acls: ['*']})
.expect(200)
.execute();
owner = await loginAccount(harness, owner);
const guild = await createBuilder<GuildResponse>(harness, owner.token)
.post('/guilds')
.body({name: `PhoneGate-${Date.now()}`})
.execute();
const invite = await createBuilder<{code: string}>(harness, owner.token)
.post(`/channels/${guild.system_channel_id}`.concat('/invites'))
.body({max_uses: 0, max_age: 0, unique: false, temporary: false})
.execute();
return {guildId: guild.id, inviteCode: invite.code};
}
async function readFlags(userId: string): Promise<number> {
const {UserRepository} = await import('../../user/repositories/UserRepository');
const {createUserID} = await import('../../BrandedTypes');
const user = await new UserRepository().findUnique(createUserID(BigInt(userId)));
return user?.suspiciousActivityFlags ?? 0;
}
describe('Deferred phone verification gate', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
setInjectedRegistrationRiskEvaluator(undefined);
await harness.reset();
});
afterAll(async () => {
setInjectedRegistrationRiskEvaluator(undefined);
await harness?.shutdown();
});
it('applies the phone requirement immediately while the gate is off', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: false});
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('gate-off'),
username: createUniqueUsername('gate_off'),
global_name: 'Gate Off',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
const flags = await readFlags(registration.user_id);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
});
it('defers the phone requirement at registration while the gate is on', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('gate-on'),
username: createUniqueUsername('gate_on'),
global_name: 'Gate On',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
const flags = await readFlags(registration.user_id);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
const me = await createBuilder<{required_actions: Array<string>}>(harness, registration.token)
.get('/users/@me')
.expect(200)
.execute();
expect(me.required_actions ?? []).toEqual([]);
});
it('lets a deferred account join a small guild without being challenged', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
const {guildId, inviteCode} = await createGuildWithInvite(harness);
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('gate-small'),
username: createUniqueUsername('gate_small'),
global_name: 'Gate Small',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
setInjectedRegistrationRiskEvaluator(undefined);
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
const flags = await readFlags(registration.user_id);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(guildId).toBeTruthy();
});
it('does not defer the inbound-SMS tier, which stays enforced from registration', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
setInjectedRegistrationRiskEvaluator({
async evaluate() {
return {
level: RiskLevel.VeryHigh,
recommendedAction: RecommendedAction.RequireInboundPhone,
assessment: {
suspicious: true,
level: RiskLevel.VeryHigh,
confidence: RiskConfidence.High,
riskScore: 90,
reasoning: 'inbound tier',
recommendedAction: RecommendedAction.RequireInboundPhone,
method: RiskDecisionMethod.Noop,
modelUsed: 'test',
rounds: 0,
elapsedMs: 0,
signals: {},
},
};
},
});
const registration = await registerUser(harness, {
email: createUniqueEmail('gate-inbound'),
username: createUniqueUsername('gate_inbound'),
global_name: 'Gate Inbound',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
const flags = await readFlags(registration.user_id);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(flags & SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION).not.toBe(0);
});
it('promotes the requirement and refuses the join on a qualifying guild inside the window', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({
deferred_phone_gate_enabled: true,
deferred_phone_gate_member_threshold: 1,
deferred_phone_gate_window_hours: 24,
});
const {inviteCode} = await createGuildWithInvite(harness);
const filler = await createTestAccount(harness);
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('gate-qualifying'),
username: createUniqueUsername('gate_qualifying'),
global_name: 'Gate Qualifying',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
setInjectedRegistrationRiskEvaluator(undefined);
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(403).execute();
const flags = await readFlags(registration.user_id);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
});
});
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {maskIpForDisplay} from '@fluxer/ip_utils/src/IpAddress';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './AuthTestUtils';
interface HandoffInitiateResponse {
@@ -17,6 +19,7 @@ interface HandoffInfoResponse {
client_info?: {
platform?: string | null;
os?: string | null;
device?: 'mobile' | 'desktop';
location?: {
city?: string | null;
region?: string | null;
@@ -25,6 +28,16 @@ interface HandoffInfoResponse {
} | null;
}
interface AuthSessionsResponseItem {
masked_ip?: string | null;
client_info?: {
platform?: string | null;
os?: string | null;
browser?: string | null;
device?: 'mobile' | 'desktop';
} | null;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
@@ -53,6 +66,46 @@ describe('Auth desktop handoff flow', () => {
afterAll(async () => {
await harness?.shutdown();
});
it('attributes the handed-off session to the initiating desktop, not the approving browser', async () => {
const DESKTOP_IP = '203.0.113.77';
const desktopUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/1.4.0 Chrome/128.0.0.0 Electron/32.0.0 Safari/537.36';
const browserUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.header('User-Agent', desktopUserAgent)
.header('x-forwarded-for', DESKTOP_IP)
.body(null)
.execute();
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.header('User-Agent', browserUserAgent)
.execute();
expect(info.client_info?.platform).toBe('Fluxer macOS');
expect(info.client_info?.device).toBe('desktop');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.header('User-Agent', browserUserAgent)
.body({code: initResp.code, token: login.token, user_id: login.userId})
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(harness, completed.token!)
.get('/auth/sessions')
.execute();
const handedOff = sessions.filter((session) => session.client_info?.platform === 'Fluxer macOS');
expect(handedOff).toHaveLength(1);
expect(handedOff[0]?.client_info?.os).toBe('macOS');
expect(handedOff[0]?.client_info?.browser).toBeNull();
expect(handedOff[0]?.client_info?.device).toBe('desktop');
expect(sessions.some((session) => session.client_info?.browser === 'Chrome')).toBe(false);
expect(handedOff[0]?.masked_ip).toBe(maskIpForDisplay(DESKTOP_IP));
});
it('completes full handoff flow: initiate → info → complete → status', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {describe, expect, it} from 'vitest';
import {createUserID} from '../../BrandedTypes';
import {EMPTY_USER_ROW, type UserRow} from '../../database/types/UserTypes';
import {User} from '../../models/User';
import {hasNoVerifiableCredential, userHasMfa} from '../services/SudoVerificationService';
function createUser(overrides: Partial<UserRow> = {}): User {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(1n),
username: 'test_user',
discriminator: 1,
bot: false,
password_hash: 'hash',
traits: new Set<string>(),
...overrides,
});
}
describe('sudo verification credential capability', () => {
it('lets an SSO provisioned account without a password satisfy sudo mode', () => {
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
expect(user.isUnclaimedAccount()).toBe(false);
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
});
it('still lets an unclaimed account satisfy sudo mode', () => {
const user = createUser({password_hash: null});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
});
it('still requires a password from accounts that have one', () => {
const user = createUser({password_hash: 'hash', traits: new Set<string>(['sso'])});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
});
it('still requires MFA from an SSO account that enrolled a second factor', () => {
const user = createUser({
password_hash: null,
traits: new Set<string>(['sso']),
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
});
expect(userHasMfa(user)).toBe(true);
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
});
it('never applies to bots', () => {
const user = createUser({password_hash: null, bot: true});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
});
});
@@ -324,7 +324,7 @@ describe('mapStripeInvoiceToRow', () => {
const inv = stripeFixture<Stripe.Invoice>({
id: 'in_1',
customer: 'cus_1',
subscription: 'sub_1',
parent: {type: 'subscription_details', subscription_details: {subscription: 'sub_1'}},
status: 'paid',
number: 'INV-001',
currency: 'usd',
@@ -94,28 +94,6 @@ export interface StripeSubscriptionPayload {
trial_start?: number | null;
}
interface StripeInvoiceCompatibility extends Stripe.Invoice {
subscription?: StripeExpandableId;
tax?: number | null;
application_fee_amount?: number | null;
}
interface StripePaymentIntentCompatibility extends Stripe.PaymentIntent {
invoice?: StripeExpandableId;
}
interface StripeChargeCompatibility extends Stripe.Charge {
invoice?: StripeExpandableId;
}
type StripeRefundCompatibility = Stripe.Refund & {
livemode?: boolean | null;
};
interface StripeCheckoutSessionCompatibility extends Stripe.Checkout.Session {
completed_at?: number | null;
}
function createBillingSubscriptionItemValue(
itemId: string,
priceId: string,
@@ -280,6 +258,14 @@ export function computeStripeUpdatedAt(
return new Date(max * 1000);
}
function sumInvoiceTotalTaxes(inv: Stripe.Invoice): bigint | null {
const taxes = inv.total_taxes ?? null;
if (taxes === null) {
return null;
}
return BigInt(taxes.reduce((total, tax) => total + (tax.amount ?? 0), 0));
}
function idOf(
ref:
| string
@@ -689,9 +675,7 @@ export function mapStripeInvoiceToRow(
throw new BillingMappingError('Invoice is missing customer', inv.id);
}
const now = new Date();
const invoice = inv as StripeInvoiceCompatibility;
const subscriptionId =
idOf(invoice.subscription ?? null) ?? idOf(invoice.parent?.subscription_details?.subscription ?? null);
const subscriptionId = idOf(inv.parent?.subscription_details?.subscription ?? null);
const userIdFromMetadata = parseUserIdFromMetadata(inv.metadata);
const userId = hints?.knownUserId ?? userIdFromMetadata;
const transitions = inv.status_transitions ?? null;
@@ -718,12 +702,11 @@ export function mapStripeInvoiceToRow(
amount_paid: typeof inv.amount_paid === 'number' ? BigInt(inv.amount_paid) : null,
amount_remaining: typeof inv.amount_remaining === 'number' ? BigInt(inv.amount_remaining) : null,
subtotal: typeof inv.subtotal === 'number' ? BigInt(inv.subtotal) : null,
tax: typeof invoice.tax === 'number' ? BigInt(invoice.tax) : null,
tax: sumInvoiceTotalTaxes(inv),
total: typeof inv.total === 'number' ? BigInt(inv.total) : null,
starting_balance: typeof inv.starting_balance === 'number' ? BigInt(inv.starting_balance) : null,
ending_balance: typeof inv.ending_balance === 'number' ? BigInt(inv.ending_balance) : null,
application_fee_amount:
typeof invoice.application_fee_amount === 'number' ? BigInt(invoice.application_fee_amount) : null,
application_fee_amount: null,
attempt_count: inv.attempt_count ?? null,
attempted: inv.attempted ?? null,
auto_advance: inv.auto_advance ?? null,
@@ -818,7 +801,6 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
throw new BillingMappingError('PaymentIntent is missing id');
}
const now = new Date();
const paymentIntent = pi as StripePaymentIntentCompatibility;
const customerId = idOf(
pi.customer as
| string
@@ -828,7 +810,7 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
| null
| undefined,
);
const invoiceId = idOf(paymentIntent.invoice ?? null);
const invoiceId = null;
const paymentMethodId = idOf(
pi.payment_method as
| string
@@ -892,7 +874,6 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
throw new BillingMappingError('Charge is missing id');
}
const now = new Date();
const charge = c as StripeChargeCompatibility;
const customerId = idOf(
c.customer as
| string
@@ -911,7 +892,7 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
| null
| undefined,
);
const invoiceId = idOf(charge.invoice ?? null);
const invoiceId = null;
const paymentMethodId = c.payment_method ?? null;
const card = c.payment_method_details?.card ?? null;
const billingDetails = c.billing_details ?? null;
@@ -974,6 +955,7 @@ export function mapStripeRefundToRow(
invoiceId?: string;
customerId?: string;
userId?: bigint;
livemode?: boolean;
},
): {
primary: BillingRefundRow;
@@ -990,7 +972,6 @@ export function mapStripeRefundToRow(
const invoiceId = hints?.invoiceId ?? null;
const customerId = hints?.customerId ?? null;
const userId = hints?.userId ?? null;
const refund = r as StripeRefundCompatibility;
const primary: BillingRefundRow = {
provider_id: r.id,
charge_id: chargeId,
@@ -1005,7 +986,7 @@ export function mapStripeRefundToRow(
receipt_number: r.receipt_number ?? null,
failure_reason: r.failure_reason ?? null,
description: r.description ?? null,
livemode: refund.livemode ?? null,
livemode: hints?.livemode ?? null,
metadata: safeMetadata(r.metadata),
stripe_created_at: unixToDate(r.created),
stripe_updated_at: computeStripeUpdatedAt({created: r.created}),
@@ -1049,16 +1030,17 @@ export function mapStripeCheckoutSessionToRow(
cs: Stripe.Checkout.Session,
hints?: {
knownUserId?: bigint;
eventCreated?: number;
},
): {
primary: BillingCheckoutSessionRow;
byCustomer: BillingCheckoutSessionByCustomerRow | null;
} {
const completedAt = cs.status === 'complete' ? (hints?.eventCreated ?? null) : null;
if (!cs.id) {
throw new BillingMappingError('Checkout session is missing id');
}
const now = new Date();
const checkoutSession = cs as StripeCheckoutSessionCompatibility;
const customerId = idOf(
cs.customer as
| string
@@ -1124,14 +1106,14 @@ export function mapStripeCheckoutSessionToRow(
amount_total: typeof cs.amount_total === 'number' ? BigInt(cs.amount_total) : null,
currency: cs.currency ?? null,
expires_at: unixToDate(cs.expires_at),
completed_at: unixToDate(checkoutSession.completed_at ?? null),
completed_at: completedAt === null ? null : unixToDate(completedAt),
livemode: cs.livemode ?? null,
client_reference_id: cs.client_reference_id ?? null,
metadata: safeMetadata(cs.metadata),
stripe_created_at: unixToDate(cs.created),
stripe_updated_at: computeStripeUpdatedAt({
created: cs.created,
completed_at: checkoutSession.completed_at ?? null,
completed_at: completedAt,
}),
mirrored_at: now,
} as BillingCheckoutSessionRow;
@@ -49,6 +49,7 @@ export class BillingCheckoutSessionRepository {
cs: Stripe.Checkout.Session,
hints?: {
knownUserId?: bigint;
eventCreated?: number;
},
): Promise<{
changed: boolean;
@@ -71,6 +71,7 @@ export class BillingRefundRepository {
invoiceId?: string;
customerId?: string;
userId?: bigint;
livemode?: boolean;
},
): Promise<{
changed: boolean;
@@ -93,19 +93,7 @@ export class MessageEditService {
assertGuildMemberCanCommunicate(member);
}
if (data.message_snapshots !== undefined) {
const isAuthor = message.authorId === userId;
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
if (!isAuthor && !canManage) {
throw new MissingPermissionsError();
}
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
this.deps.persistenceService.updateSnapshotAttachments({
message,
snapshotEdits: data.message_snapshots ?? [],
}),
);
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
return updatedMessage;
throw new MissingPermissionsError();
}
const user = await this.deps.userRepository.findUnique(userId);
this.deps.validationService.validateMessageEditable(message);
@@ -929,9 +929,8 @@ export class MessageSendService {
algorithm: 'leaky_bucket',
});
if (!slowmodeResult.allowed) {
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
throw new SlowmodeRateLimitError({
retryAfter,
retryAfter: slowmodeResult.retryAfter,
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
});
}
@@ -160,12 +160,8 @@ export class MessageValidationService {
}
}
calculateMessageFlags(data: {flags?: number; favorite_meme_id?: bigint | null}): number {
let flags = data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
if (data.favorite_meme_id) {
flags |= MessageFlags.COMPACT_ATTACHMENTS;
}
return flags;
calculateMessageFlags(data: {flags?: number}): number {
return data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
}
validateTotalAttachmentSize(
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.id).toBe(firstMessage.id);
});
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
const rateLimitPerUser = 5;
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await ensureSessionStarted(harness, member.token);
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
await updateChannel(harness, owner.token, channel.id, {
rate_limit_per_user: rateLimitPerUser,
});
await sendChannelMessage(harness, member.token, channel.id, 'first message');
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'second message'})
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
.executeWithResponse();
const headerRetryAfter = Number(response.headers.get('Retry-After'));
expect(Number.isInteger(headerRetryAfter)).toBe(true);
expect(headerRetryAfter).toBeGreaterThan(0);
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
expect(json.retry_after).toBeGreaterThan(0);
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
});
});
+12
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
@@ -150,6 +151,7 @@ export interface APIConfig {
static: string;
};
};
s3Downloads: ResolvedDownloadsProvider;
email: {
enabled: boolean;
provider: 'smtp' | 'none';
@@ -327,6 +329,8 @@ export interface APIConfig {
testHarnessToken?: string;
};
presignedAttachmentUploadsEnabled: boolean;
presignedDownloadsEnabled: boolean;
presignedHarvestDownloadsEnabled: boolean;
attachmentDecayEnabled: boolean;
deletionGracePeriodHours: number;
inactivityDeletionThresholdDays?: number;
@@ -358,6 +362,14 @@ export interface APIConfig {
taskName?: WorkerTaskName;
enableCronScheduler?: boolean;
enableVoiceReconciliation: boolean;
voiceReconciliation: {
intervalMs: number | undefined;
staggerDelayMs: number | undefined;
lockTtlSeconds: number | undefined;
cadenceTtlSeconds: number | undefined;
gatewayOnlyGraceMs: number | undefined;
liveKitOnlyGraceMs: number | undefined;
};
laneConcurrencyOverrides: {
realtime?: number;
unfurl?: number;
@@ -18,9 +18,7 @@ export interface AuthSessionRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os?: Nullish<string>;
client_platform?: Nullish<string>;
client_os: Nullish<string>;
client_country: Nullish<string>;
version: number;
}
@@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os: Nullish<string>;
client_platform: Nullish<string>;
client_country: Nullish<string>;
deleted_at: Date;
version: number;
@@ -140,9 +136,7 @@ export const AUTH_SESSION_COLUMNS = [
'approx_last_used_at',
'client_ip',
'client_user_agent',
'client_is_desktop',
'client_os',
'client_platform',
'client_country',
'version',
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;
@@ -153,9 +147,7 @@ export const AUTH_SESSION_TOMBSTONE_COLUMNS = [
'approx_last_used_at',
'client_ip',
'client_user_agent',
'client_is_desktop',
'client_os',
'client_platform',
'client_country',
'deleted_at',
'version',
@@ -18,7 +18,12 @@ import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import type {HonoEnv} from '../types/HonoEnv';
import {Validator} from '../Validator';
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
import {DESKTOP_REDIRECT_PREFIX, DOWNLOAD_PREFIX, UnsatisfiableRangeError} from './DownloadService';
import {
DESKTOP_REDIRECT_PREFIX,
DOWNLOAD_PREFIX,
downloadCacheControlForKey,
UnsatisfiableRangeError,
} from './DownloadService';
function artifactFilename(key: string, filenameOverride?: string): string {
return filenameOverride ?? key.split('/').pop() ?? 'download';
@@ -73,6 +78,8 @@ async function headArtifactResponse(
return new Response(null, {status: 200, headers});
}
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
async function streamArtifactResponse(
ctx: Context<HonoEnv>,
downloadService: DownloadService,
@@ -83,6 +90,24 @@ async function streamArtifactResponse(
if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
}
if (downloadService.isPresignedDownloadEnabled()) {
const location = await downloadService.getPresignedDownloadRedirect({
key,
filename: artifactFilename(key, filenameOverride),
expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS,
});
if (!location) {
return ctx.text('Not Found', 404);
}
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': 'no-store',
'Accept-Ranges': 'bytes',
}),
});
}
const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null;
try {
@@ -270,7 +295,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
if (!checksum) {
return ctx.text('Not Found', 404);
}
return checksumFileResponse(ctx, checksum, 'public, max-age=86400');
return checksumFileResponse(ctx, checksum, downloadCacheControlForKey(checksum.key));
},
);
routes.on(
@@ -296,7 +321,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=86400');
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
);
routes.on(
@@ -320,7 +345,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=300');
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
);
}
+57 -4
View File
@@ -51,6 +51,35 @@ function desktopBucketPrefix(test?: boolean): string {
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
}
const MUTABLE_DOWNLOAD_CACHE_CONTROL = 'public, max-age=300';
const VERSIONED_ARTIFACT_CACHE_CONTROL = 'public, max-age=31536000';
function isDesktopReleaseFeedFilename(filename: string): boolean {
return (
filename === 'manifest.json' ||
filename.endsWith('.yml') ||
filename.endsWith('.yaml') ||
filename.startsWith('RELEASES') ||
(filename.startsWith('releases') && filename.endsWith('.json')) ||
(filename.startsWith('assets') && filename.endsWith('.json'))
);
}
function isVersionedDesktopArtifactKey(key: string): boolean {
if (!key.startsWith(`${DESKTOP_BUCKET_PREFIX}/`)) {
return false;
}
const filename = key.split('/').pop() ?? '';
if (filename.length === 0) {
return false;
}
return !isDesktopReleaseFeedFilename(filename);
}
export function downloadCacheControlForKey(key: string): string {
return isVersionedDesktopArtifactKey(key) ? VERSIONED_ARTIFACT_CACHE_CONTROL : MUTABLE_DOWNLOAD_CACHE_CONTROL;
}
function desktopArtifactPrefix(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
@@ -108,6 +137,7 @@ type VersionInfo = {
files: Record<string, VersionFile>;
};
export type DesktopChecksumFile = {
key: string;
filename: string;
sha256: string;
body: string;
@@ -461,7 +491,7 @@ export class DownloadService {
return null;
}
const filename = this.filenameFromKey(key);
return this.buildDesktopChecksumFile(filename, file.sha256);
return this.buildDesktopChecksumFile(key, filename, file.sha256);
}
async resolveVersionedDesktopChecksumFile(params: {
@@ -479,14 +509,14 @@ export class DownloadService {
const filename = this.filenameFromKey(key);
const objectSha256 = await this.readDesktopSha256ForArtifactKey(key);
if (objectSha256) {
return this.buildDesktopChecksumFile(filename, objectSha256);
return this.buildDesktopChecksumFile(key, filename, objectSha256);
}
const latest = await this.getLatestDesktopVersion(params);
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
return this.buildDesktopChecksumFile(filename, file.sha256);
return this.buildDesktopChecksumFile(key, filename, file.sha256);
}
async resolveDownloadKey(params: {path: string; test?: boolean}): Promise<string | null> {
@@ -533,6 +563,28 @@ export class DownloadService {
}
}
isPresignedDownloadEnabled(): boolean {
return Config.presignedDownloadsEnabled;
}
async getPresignedDownloadRedirect(params: {
key: string;
filename: string;
expiresIn: number;
}): Promise<string | null> {
const metadata = await this.getDownloadMetadata({key: params.key});
if (!metadata) {
return null;
}
return this.storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.downloads,
key: params.key,
expiresIn: params.expiresIn,
responseContentType: metadata.contentType ?? 'application/octet-stream',
responseContentDisposition: `attachment; filename="${encodeURIComponent(params.filename)}"`,
});
}
async getDownloadMetadata(params: {key: string}): Promise<{
contentLength: number;
contentType?: string | null;
@@ -1129,8 +1181,9 @@ export class DownloadService {
return key.split('/').pop() ?? 'download';
}
private buildDesktopChecksumFile(filename: string, sha256: string): DesktopChecksumFile {
private buildDesktopChecksumFile(key: string, filename: string, sha256: string): DesktopChecksumFile {
return {
key,
filename,
sha256,
body: `${sha256} ${filename}\n`,
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const OBJECT_METADATA = {
contentLength: 285_567_850,
contentType: 'application/x-apple-diskimage',
etag: '"abc123"',
lastModified: new Date('2026-08-17T00:00:00Z'),
};
interface PresignCall {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}
function createService(overrides: {metadata?: typeof OBJECT_METADATA | null} = {}) {
const presignCalls: Array<PresignCall> = [];
const storageService = {
getObjectMetadata: async () => (overrides.metadata === undefined ? OBJECT_METADATA : overrides.metadata),
getPresignedDownloadURL: async (params: PresignCall) => {
presignCalls.push(params);
return `https://storage.example.test/${params.key}?signed=1`;
},
} as unknown as IStorageService;
return {service: new DownloadService(storageService), presignCalls};
}
describe('presigned download redirects', () => {
it('signs the requested object and returns its URL', async () => {
const {service, presignCalls} = createService();
const url = await service.getPresignedDownloadRedirect({
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
filename: 'Fluxer.dmg',
expiresIn: 900,
});
expect(url).toBe('https://storage.example.test/desktop/canary/darwin/universal/Fluxer.dmg?signed=1');
expect(presignCalls).toHaveLength(1);
expect(presignCalls[0]?.key).toBe('desktop/canary/darwin/universal/Fluxer.dmg');
expect(presignCalls[0]?.expiresIn).toBe(900);
});
it('preserves the download filename and content type through the redirect', async () => {
const {service, presignCalls} = createService();
await service.getPresignedDownloadRedirect({
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
filename: 'Fluxer Canary.dmg',
expiresIn: 900,
});
expect(presignCalls[0]?.responseContentType).toBe('application/x-apple-diskimage');
expect(presignCalls[0]?.responseContentDisposition).toBe(
`attachment; filename="${encodeURIComponent('Fluxer Canary.dmg')}"`,
);
});
it('falls back to a binary content type when storage reports none', async () => {
const {service, presignCalls} = createService({
metadata: {...OBJECT_METADATA, contentType: null} as unknown as typeof OBJECT_METADATA,
});
await service.getPresignedDownloadRedirect({key: 'desktop/x.bin', filename: 'x.bin', expiresIn: 900});
expect(presignCalls[0]?.responseContentType).toBe('application/octet-stream');
});
it('returns null for a missing object so the caller can answer 404 without signing', async () => {
const {service, presignCalls} = createService({metadata: null});
const url = await service.getPresignedDownloadRedirect({
key: 'desktop/missing.dmg',
filename: 'missing.dmg',
expiresIn: 900,
});
expect(url).toBeNull();
expect(presignCalls).toHaveLength(0);
});
});
@@ -2,6 +2,7 @@
import {Logger} from '@fluxer/logger/src/Logger';
import type {ResolvedGifEntrySchema} from '@fluxer/schema/src/domains/gif/FavoriteGifSchemas';
import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys';
import type {GifMediaFormat, GifResponse} from '@fluxer/schema/src/domains/gif/GifSchemas';
import type {EmbedMediaResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {tryExtractGifProviderSlug} from '../gif/GifProviderUtils';
@@ -10,17 +11,6 @@ import type {IGifProvider} from '../gif/IGifProvider';
import type {IMediaService, MediaProxyMetadataResponse} from '../infrastructure/IMediaService';
import type {IUnfurlerService} from '../infrastructure/IUnfurlerService';
const PREVIEW_FORMAT_PRIORITY = ['webm', 'mp4', 'tinywebm', 'tinymp4', 'webp', 'gif', 'tinygif', 'nanogif'] as const;
const FORMAT_CONTENT_TYPES: Record<string, string> = {
webm: 'video/webm',
tinywebm: 'video/webm',
mp4: 'video/mp4',
tinymp4: 'video/mp4',
webp: 'image/webp',
gif: 'image/gif',
tinygif: 'image/gif',
nanogif: 'image/gif',
};
const logger = new Logger('FavoriteGifResolver');
function pickFavoriteGifPreviewFormat(
@@ -221,10 +211,6 @@ function isUsableGifMediaFormat(format: GifMediaFormat | undefined): format is G
return Boolean(format?.src && format.proxy_src && format.width > 0 && format.height > 0);
}
function inferFormatContentType(formatKey: string): string {
return FORMAT_CONTENT_TYPES[formatKey] ?? '';
}
function isRenderableMediaType(contentType: string | null | undefined): boolean {
if (!contentType) return false;
return contentType.startsWith('image/') || contentType.startsWith('video/');
@@ -50,7 +50,9 @@ export function GuildBaseController(app: HonoApp) {
if (policy.single_community_enabled) {
throw new SingleCommunityCannotCreateGuildsError();
}
requireEmailVerified(user, 'guild_creation');
if (!user.isUnclaimedAccount()) {
requireEmailVerified(user, 'guild_creation');
}
const auditLogReason = ctx.get('auditLogReason') ?? null;
const locale = ctx.get('requestLocale') ?? null;
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
@@ -104,6 +104,7 @@ export function GuildDiscoveryController(app: HonoApp) {
app.post(
'/discovery/guilds/:guild_id/join',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
LoginRequired,
DefaultUserOnly,
Validator('param', GuildIdParam),
OpenAPI({
@@ -319,6 +319,7 @@ export class GuildMemberService {
sendJoinMessage?: boolean;
skipGuildLimitCheck?: boolean;
skipBanCheck?: boolean;
skipRiskGate?: boolean;
isTemporary?: boolean;
joinSourceType?: JoinSourceType;
sourceInviteCode?: InviteCode;
@@ -2,10 +2,17 @@
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {type JoinSourceType, JoinSourceTypes, SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
import {
GuildFeatures,
type JoinSourceType,
JoinSourceTypes,
SystemChannelFlags,
} from '@fluxer/constants/src/GuildConstants';
import {
DEFAULT_GUILD_FOLDER_ICON,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
type MentionReplyPreference,
PHONE_REQUIREMENT_FLAGS,
UserNotificationSettings,
} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -17,10 +24,12 @@ import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError';
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError';
import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
@@ -38,13 +47,24 @@ import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
import {profileSubstringBlocklistCache} from '../../../middleware/ProfileSubstringBlocklistCache';
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
import type {Guild} from '../../../models/Guild';
import type {GuildMember} from '../../../models/GuildMember';
import type {User} from '../../../models/User';
import type {UserGuildSettings} from '../../../models/UserGuildSettings';
import type {UserSettings} from '../../../models/UserSettings';
import {
DEFAULT_PHONE_GATE_MEMBER_THRESHOLD,
evaluateDeferredPhoneGate,
getDeferredPhoneGateConfig,
guildTriggersPhoneGate,
} from '../../../risk/DeferredPhoneGate';
import type {IUserRepository} from '../../../user/IUserRepository';
import {isProfileSubstringExempt} from '../../../user/UserHelpers';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '../../../user/UserMappers';
import {getEffectiveSuspiciousFlags, isProfileSubstringExempt} from '../../../user/UserHelpers';
import {
mapUserGuildSettingsToResponse,
mapUserSettingsToResponse,
mapUserToPrivateResponse,
} from '../../../user/UserMappers';
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '../../../user/utils/GuildFolderUtils';
import type {GuildAuditLogService} from '../../GuildAuditLogService';
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
@@ -374,6 +394,68 @@ export class GuildMemberOperationsService {
await this.gatewayService.leaveGuild({userId: targetId, guildId});
}
private async applyDeferredPhoneGate(user: User, guild: Guild): Promise<void> {
if (user.hasVerifiedPhone) {
return;
}
const rawFlags = user.suspiciousActivityFlags ?? 0;
if ((rawFlags & (DEFERRED_PHONE_ON_COMMUNITY_JOIN | PHONE_REQUIREMENT_FLAGS)) === 0) {
return;
}
const {status, config} = await getDeferredPhoneGateConfig();
const logContext = {
userId: user.id.toString(),
guildId: guild.id.toString(),
discoverable: guild.features.has(GuildFeatures.DISCOVERABLE),
memberCount: guild.memberCount,
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
};
if (status !== 'ok') {
const undeferredFlags = getEffectiveSuspiciousFlags({
...user,
suspiciousActivityFlags: rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN,
} as User);
if (
(undeferredFlags & PHONE_REQUIREMENT_FLAGS) === 0 ||
!guildTriggersPhoneGate(guild, DEFAULT_PHONE_GATE_MEMBER_THRESHOLD)
) {
return;
}
Logger.info(logContext, `deferred_phone_gate.enforced_while_${status}`);
throw new AccountSuspiciousActivityError(undeferredFlags);
}
const liveFlags = getEffectiveSuspiciousFlags(user);
if ((liveFlags & PHONE_REQUIREMENT_FLAGS) !== 0) {
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
return;
}
Logger.info(logContext, 'deferred_phone_gate.blocked_unsatisfied_phone_requirement');
throw new AccountSuspiciousActivityError(liveFlags);
}
const outcome = evaluateDeferredPhoneGate(user, guild, config, Date.now());
if (!outcome.applies) {
Logger.info(logContext, `deferred_phone_gate.skipped_${outcome.reason}`);
return;
}
const promotedFlags = getEffectiveSuspiciousFlags({...user, suspiciousActivityFlags: outcome.flags} as User);
if (promotedFlags === 0) {
Logger.info(logContext, 'deferred_phone_gate.skipped_unenforceable');
return;
}
const updatedUser = await this.userRepository.patchUpsert(
user.id,
{suspicious_activity_flags: outcome.flags},
user.toRow(),
);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
Logger.info(logContext, 'deferred_phone_gate.applied');
throw new AccountSuspiciousActivityError(promotedFlags);
}
async addUserToGuild(
params: {
userId: UserID;
@@ -381,6 +463,7 @@ export class GuildMemberOperationsService {
sendJoinMessage?: boolean;
skipGuildLimitCheck?: boolean;
skipBanCheck?: boolean;
skipRiskGate?: boolean;
isTemporary?: boolean;
joinSourceType?: JoinSourceType;
sourceInviteCode?: InviteCode;
@@ -398,6 +481,7 @@ export class GuildMemberOperationsService {
sendJoinMessage = true,
skipGuildLimitCheck = false,
skipBanCheck = false,
skipRiskGate = false,
isTemporary = false,
joinSourceType = JoinSourceTypes.INSTANT_INVITE,
sourceInviteCode = null,
@@ -418,6 +502,9 @@ export class GuildMemberOperationsService {
if (!skipGuildLimitCheck) {
await this.enforceGuildLimit(user, userGuildsCount);
}
if (!skipRiskGate && !user.isBot) {
await this.applyDeferredPhoneGate(user, guild);
}
const maxGuildMembers = resolveMaxGuildMembersLimit({
guildFeatures: guild.features,
snapshot: this.limitConfigService.getConfigSnapshot(),
@@ -72,7 +72,13 @@ export interface IStorageService {
destinationKey: string;
newContentType?: string;
}): Promise<void>;
getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise<string>;
getPresignedDownloadURL(params: {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string>;
getPresignedUploadURL(params: {
bucket: string;
key: string;
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
]);
});
});
describe('provider selection', () => {
interface ClientProbe {
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
}
it('defaults to the shared S3 configuration', async () => {
const service = new StorageService() as unknown as ClientProbe;
expect(await service.client.config.region()).toBe(Config.s3.region);
});
it('uses an explicitly supplied provider instead of the shared one', async () => {
const service = new StorageService({
endpoint: 'https://downloads.example.net',
forcePathStyle: false,
region: 'eu-central-9',
accessKeyId: 'DL_KEY',
secretAccessKey: 'DL_SECRET',
}) as unknown as ClientProbe;
expect(await service.client.config.region()).toBe('eu-central-9');
expect(await service.client.config.region()).not.toBe(Config.s3.region);
});
});
@@ -26,6 +26,7 @@ import {
} from '@aws-sdk/client-s3';
import {Upload} from '@aws-sdk/lib-storage';
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
import {seconds} from 'itty-time';
import {temporaryFile} from 'tempy';
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
export class StorageService implements IStorageService {
private readonly client: S3Client;
private readonly presignClient: S3Client;
private readonly provider: S3ProviderSettings;
constructor() {
this.client = buildPooledS3Client({
constructor(provider?: S3ProviderSettings) {
this.provider = provider ?? {
endpoint: Config.s3.endpoint,
presignedUrlBase: Config.s3.presignedUrlBase,
forcePathStyle: Config.s3.forcePathStyle,
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
};
this.client = buildPooledS3Client({
endpoint: this.provider.endpoint,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: true,
});
this.presignClient = buildPooledS3Client({
endpoint: this.resolvePresignEndpoint(),
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
forcePathStyle: Config.s3.forcePathStyle,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: this.provider.forcePathStyle,
});
}
private resolvePresignEndpoint(): string {
const fallbackEndpoint = Config.s3.endpoint;
const configuredEndpoint = Config.s3.presignedUrlBase;
const fallbackEndpoint = this.provider.endpoint;
const configuredEndpoint = this.provider.presignedUrlBase;
if (!configuredEndpoint) {
return fallbackEndpoint;
}
@@ -241,14 +251,20 @@ export class StorageService implements IStorageService {
bucket,
key,
expiresIn = seconds('5 minutes'),
responseContentType,
responseContentDisposition,
}: {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
ResponseContentType: responseContentType,
ResponseContentDisposition: responseContentDisposition,
});
return getSignedUrl(this.presignClient, command, {expiresIn});
}
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {Config} from '../Config';
import {createDownloadsStorageService} from './StorageServiceFactory';
describe('createDownloadsStorageService', () => {
it('returns null when no downloads override is configured', () => {
expect(Config.s3Downloads.isOverridden).toBe(false);
expect(createDownloadsStorageService()).toBeNull();
});
it('resolves the downloads provider to the shared provider by default', () => {
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
});
});
@@ -1,8 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import type {IStorageService} from './IStorageService';
import {StorageService} from './StorageService';
export function createStorageService(): IStorageService {
return new StorageService();
}
export function createDownloadsStorageService(): IStorageService | null {
if (!Config.s3Downloads.isOverridden) {
return null;
}
return new StorageService(Config.s3Downloads.settings);
}
@@ -13,6 +13,7 @@ import {sanitizeLimitConfigForInstance} from '../constants/LimitConfig';
import {fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import type {InstanceConfigurationRow} from '../database/types/InstanceConfigTypes';
import {Logger} from '../Logger';
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
import {InstanceConfiguration} from '../Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay';
import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils';
@@ -84,6 +85,9 @@ export interface InstancePolicyConfig {
gif_enabled: boolean | null;
youtube_enabled: boolean | null;
bluesky_enabled: boolean | null;
deferred_phone_gate_enabled: boolean;
deferred_phone_gate_window_hours: number;
deferred_phone_gate_member_threshold: number;
}
interface InstanceCommunityPublicConfig {
@@ -402,6 +406,9 @@ const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
gif_enabled: null,
youtube_enabled: null,
bluesky_enabled: null,
deferred_phone_gate_enabled: false,
deferred_phone_gate_window_hours: 6,
deferred_phone_gate_member_threshold: 50,
};
function isPremiumMode(value: unknown): value is InstancePremiumMode {
@@ -412,6 +419,13 @@ function normalizeNullableBoolean(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return value;
}
function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
if (!isJsonRecord(value)) {
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
@@ -425,6 +439,15 @@ function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
gif_enabled: normalizeNullableBoolean(value.gif_enabled),
youtube_enabled: normalizeNullableBoolean(value.youtube_enabled),
bluesky_enabled: normalizeNullableBoolean(value.bluesky_enabled),
deferred_phone_gate_enabled: value.deferred_phone_gate_enabled === true,
deferred_phone_gate_window_hours: normalizePositiveNumber(
value.deferred_phone_gate_window_hours,
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_window_hours,
),
deferred_phone_gate_member_threshold: normalizePositiveNumber(
value.deferred_phone_gate_member_threshold,
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_member_threshold,
),
};
}
@@ -913,12 +936,18 @@ export class InstanceConfigRepository {
this.refreshRequested = false;
this.configCache = await this.fetchAllConfigsFromDatabase();
} while (this.refreshRequested);
this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
private syncDeferredPhoneGateCache(raw: string | null): void {
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
}
private updateCachedConfigs(entries: Array<[string, string]>): void {
if (!this.configCache) {
return;
@@ -1079,16 +1108,16 @@ export class InstanceConfigRepository {
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
if (!raw) {
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
}
return normalizeInstancePolicyConfig(parseJsonRecord(raw));
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
return policy;
}
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
const current = await this.getInstancePolicyConfig();
const next = normalizeInstancePolicyConfig({...current, ...config});
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
return next;
}
@@ -35,6 +35,7 @@ export class SingleCommunityService {
}
try {
await this.guildMemberService.addUserToGuild({
skipRiskGate: true,
userId,
guildId,
skipGuildLimitCheck: true,
@@ -69,7 +69,8 @@ export class LimitConfigService {
}
async refreshCache(): Promise<void> {
this.premiumMode = (await this.repository.getInstancePolicyConfig()).premium_mode;
const policyConfig = await this.repository.getInstancePolicyConfig();
this.premiumMode = policyConfig.premium_mode;
setCachedInstancePremiumMode(this.premiumMode);
const currentHash = computeDefaultsHash();
const lockToken = await this.cacheService.acquireLock(LIMIT_CONFIG_REFRESH_LOCK_KEY, 10);
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
import {createStorageService} from '../infrastructure/StorageServiceFactory';
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
import {UserCacheService} from '../infrastructure/UserCacheService';
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
import {VirusScanService} from '../infrastructure/VirusScanService';
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
const fallback = singleton(() => createStorageService());
return () => _injectedStorageService ?? fallback();
})();
const getDownloadsStorageService: () => IStorageService = (() => {
const override = singleton(() => createDownloadsStorageService());
return () => override() ?? getStorageService();
})();
export const getErrorI18nService = singleton(() => new ErrorI18nService());
export const getLimitConfigService = singleton(
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
return accountDeletionQueue;
}
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
const getNcmecRepository = singleton(() => new NcmecRepository());
+2 -12
View File
@@ -10,9 +10,7 @@ export class AuthSession {
readonly approximateLastUsedAt: Date;
readonly clientIp: string;
readonly clientUserAgent: string | null;
readonly clientIsDesktop: boolean | null;
readonly clientOs?: string | null;
readonly clientPlatform?: string | null;
readonly clientOs: string | null;
readonly clientCountry: string | null;
readonly version: number;
@@ -23,9 +21,7 @@ export class AuthSession {
this.approximateLastUsedAt = row.approx_last_used_at;
this.clientIp = row.client_ip;
this.clientUserAgent = row.client_user_agent ?? null;
this.clientIsDesktop = row.client_is_desktop ?? null;
this.clientOs = row.client_os ?? null;
this.clientPlatform = row.client_platform ?? null;
this.clientCountry = row.client_country ?? null;
this.version = row.version;
}
@@ -38,9 +34,7 @@ export class AuthSession {
approx_last_used_at: this.approximateLastUsedAt,
client_ip: this.clientIp,
client_user_agent: this.clientUserAgent,
client_is_desktop: this.clientIsDesktop,
client_os: this.clientOs,
client_platform: this.clientPlatform,
client_country: this.clientCountry,
version: this.version,
};
@@ -54,9 +48,7 @@ export class AuthSessionTombstone {
readonly approximateLastUsedAt: Date;
readonly clientIp: string;
readonly clientUserAgent: string | null;
readonly clientIsDesktop: boolean | null;
readonly clientOs?: string | null;
readonly clientPlatform?: string | null;
readonly clientOs: string | null;
readonly clientCountry: string | null;
readonly deletedAt: Date;
readonly version: number;
@@ -68,9 +60,7 @@ export class AuthSessionTombstone {
this.approximateLastUsedAt = row.approx_last_used_at;
this.clientIp = row.client_ip;
this.clientUserAgent = row.client_user_agent ?? null;
this.clientIsDesktop = row.client_is_desktop ?? null;
this.clientOs = row.client_os ?? null;
this.clientPlatform = row.client_platform ?? null;
this.clientCountry = row.client_country ?? null;
this.deletedAt = row.deleted_at;
this.version = row.version;
@@ -273,6 +273,7 @@ export class OAuth2RequestService {
}
}
await this.guildService.members.addUserToGuild({
skipRiskGate: true,
userId: botUserId,
guildId,
skipGuildLimitCheck: true,
+96 -10
View File
@@ -10588,6 +10588,70 @@
]
}
},
"/harvest-downloads/{harvestId}": {
"get": {
"operationId": "download_data_harvest_archive",
"summary": "Download data harvest archive",
"tags": ["Users"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Download data harvest archive"}},
"description": "Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.",
"parameters": [
{
"name": "harvestId",
"in": "path",
"required": true,
"schema": {"type": "string"},
"description": "The harvestId"
}
]
}
},
"/invites/{invite_code}": {
"get": {
"operationId": "get_invite",
@@ -26040,11 +26104,18 @@
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "The operating system of the requesting device"
},
"device": {
"enum": ["mobile", "desktop"],
"type": "string",
"x-enumNames": ["mobile", "desktop"],
"description": "Device class of the requesting device, decided by the server"
},
"location": {
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
"description": "The approximate location of the requesting device"
}
}
},
"required": ["device"]
},
{"type": "null"}
],
@@ -26288,11 +26359,18 @@
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "The browser reported by the client"
},
"device": {
"enum": ["mobile", "desktop"],
"type": "string",
"x-enumNames": ["mobile", "desktop"],
"description": "Device class of the session, decided by the server"
},
"location": {
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
"description": "The geolocation data sent by the client"
}
}
},
"required": ["device"]
},
{"type": "null"}
],
@@ -26837,7 +26915,7 @@
},
{"type": "null"}
],
"description": "The message that this message is replying to or forwarding"
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence."
}
},
"required": [
@@ -26871,8 +26949,7 @@
"value": "4096",
"description": "This message will not trigger push or desktop notifications"
},
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"},
{"name": "COMPACT_ATTACHMENTS", "value": "131072", "description": "Display attachments in a compact format"}
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"}
],
"description": "Message flags bitfield"
},
@@ -27388,9 +27465,9 @@
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 25,
"maximum": 50,
"format": "int32",
"description": "Number of messages to return for this channel (1-25)"
"description": "Number of messages to return for this channel (1-50)"
},
"before": {"$ref": "#/components/schemas/SnowflakeType"},
"after": {"$ref": "#/components/schemas/SnowflakeType"},
@@ -33239,12 +33316,20 @@
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"refunded_amount_cents": {"type": "integer", "format": "int53"},
"refunded_amount_cents": {
"type": "integer",
"format": "int53",
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
},
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
"currency": {"type": "string"},
"subscription_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Subscription that was cancelled along with the refund, when applicable"
},
"status": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
}
},
"required": [
@@ -33255,7 +33340,8 @@
"refunded_amount_cents",
"invoice_amount_paid_cents",
"currency",
"subscription_id"
"subscription_id",
"status"
]
},
"ReadStateAckResponse": {
@@ -35873,7 +35959,7 @@
"HarvestDownloadUrlResponse": {
"type": "object",
"properties": {
"download_url": {"type": "string", "description": "The presigned URL to download the harvest archive"},
"download_url": {"type": "string", "description": "The temporary URL to download the harvest archive"},
"expires_at": {"type": "string", "description": "ISO 8601 timestamp when the harvest download expires"}
},
"required": ["download_url", "expires_at"]
@@ -216,6 +216,10 @@ export const UserRateLimitConfigs = {
bucket: 'user:harvest:download',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_HARVEST_DOWNLOAD_FILE: {
bucket: 'user:harvest:download_file',
config: {limit: 60, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_ENTRANCE_SOUND_LIST: {
bucket: 'user:entrance_sound:list',
config: {limit: 30, windowMs: ms('1 minute')},
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {ms} from 'itty-time';
import {describe, expect, it} from 'vitest';
import type {Guild} from '../models/Guild';
import type {User} from '../models/User';
import {type DeferredPhoneGateConfig, evaluateDeferredPhoneGate, guildTriggersPhoneGate} from './DeferredPhoneGate';
import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache';
const CONFIG: DeferredPhoneGateConfig = {
enabled: true,
windowMs: 6 * ms('1 hour'),
memberThreshold: 50,
};
const USER_SNOWFLAKE = 1485046297690587136n;
const REGISTERED_AT = snowflakeToDate(USER_SNOWFLAKE).getTime();
function createUser(overrides: Partial<Pick<User, 'hasVerifiedPhone' | 'suspiciousActivityFlags'>> = {}): User {
return {
id: USER_SNOWFLAKE,
hasVerifiedPhone: false,
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
...overrides,
} as unknown as User;
}
function createGuild(overrides: {discoverable?: boolean; memberCount?: number} = {}): Guild {
return {
id: 1n,
features: new Set(overrides.discoverable ? [GuildFeatures.DISCOVERABLE] : []),
memberCount: overrides.memberCount ?? 10,
} as unknown as Guild;
}
describe('evaluateDeferredPhoneGate', () => {
it('applies to a discoverable guild inside the window, promoting the real phone flags', () => {
const outcome = evaluateDeferredPhoneGate(
createUser(),
createGuild({discoverable: true, memberCount: 3}),
CONFIG,
REGISTERED_AT + ms('1 hour'),
);
expect(outcome.applies).toBe(true);
if (!outcome.applies) return;
expect(outcome.flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
expect(outcome.flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
});
it('applies to a large non-discoverable guild inside the window', () => {
const outcome = evaluateDeferredPhoneGate(
createUser(),
createGuild({memberCount: 51}),
CONFIG,
REGISTERED_AT + ms('1 hour'),
);
expect(outcome.applies).toBe(true);
});
it('does not apply to a small non-discoverable guild', () => {
const outcome = evaluateDeferredPhoneGate(
createUser(),
createGuild({memberCount: 50}),
CONFIG,
REGISTERED_AT + ms('1 hour'),
);
expect(outcome).toEqual({applies: false, reason: 'guild_below_threshold'});
});
it('applies on the last millisecond inside the window', () => {
const outcome = evaluateDeferredPhoneGate(
createUser(),
createGuild({discoverable: true}),
CONFIG,
REGISTERED_AT + CONFIG.windowMs - 1,
);
expect(outcome.applies).toBe(true);
});
it('does not gate once the window has elapsed, and mutates nothing', () => {
const outcome = evaluateDeferredPhoneGate(
createUser(),
createGuild({discoverable: true}),
CONFIG,
REGISTERED_AT + CONFIG.windowMs,
);
expect(outcome).toEqual({applies: false, reason: 'outside_window'});
});
it('is inert while the gate is disabled, even on a qualifying guild', () => {
const outcome = evaluateDeferredPhoneGate(
createUser(),
createGuild({discoverable: true}),
{...CONFIG, enabled: false},
REGISTERED_AT + ms('1 hour'),
);
expect(outcome).toEqual({applies: false, reason: 'gate_disabled'});
});
it('does not apply to a user who already has a verified phone', () => {
const outcome = evaluateDeferredPhoneGate(
createUser({hasVerifiedPhone: true}),
createGuild({discoverable: true}),
CONFIG,
REGISTERED_AT + ms('1 hour'),
);
expect(outcome).toEqual({applies: false, reason: 'already_verified'});
});
it('preserves non-phone requirements when promoting', () => {
const outcome = evaluateDeferredPhoneGate(
createUser({
suspiciousActivityFlags:
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION |
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
}),
createGuild({discoverable: true}),
CONFIG,
REGISTERED_AT + ms('1 hour'),
);
expect(outcome.applies).toBe(true);
if (!outcome.applies) return;
expect(outcome.flags).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
);
});
});
describe('resolveDeferredPhoneGateEnabled', () => {
it('is on only when the tunable is set and the instance is not single-community', () => {
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: false})).toBe(
true,
);
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: true})).toBe(
false,
);
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: false, single_community_enabled: false})).toBe(
false,
);
});
});
describe('guildTriggersPhoneGate', () => {
it('qualifies a discoverable guild regardless of size', () => {
expect(guildTriggersPhoneGate(createGuild({discoverable: true, memberCount: 1}), 50)).toBe(true);
});
it('qualifies a guild strictly above the member threshold', () => {
expect(guildTriggersPhoneGate(createGuild({memberCount: 51}), 50)).toBe(true);
expect(guildTriggersPhoneGate(createGuild({memberCount: 50}), 50)).toBe(false);
});
});
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
NEVER_DEFERRABLE_PHONE_FLAGS,
} from '@fluxer/constants/src/UserConstants';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {ms} from 'itty-time';
import {Logger} from '../Logger';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {Guild} from '../models/Guild';
import type {User} from '../models/User';
import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache';
export interface DeferredPhoneGateConfig {
enabled: boolean;
windowMs: number;
memberThreshold: number;
}
export const DEFAULT_PHONE_GATE_MEMBER_THRESHOLD = 50;
const DISABLED_CONFIG: DeferredPhoneGateConfig = {
enabled: false,
windowMs: Number.POSITIVE_INFINITY,
memberThreshold: Number.POSITIVE_INFINITY,
};
type DeferredPhoneGateConfigResult =
| {status: 'ok'; config: DeferredPhoneGateConfig}
| {status: 'disabled'; config: DeferredPhoneGateConfig}
| {status: 'unreadable'; config: DeferredPhoneGateConfig};
export async function getDeferredPhoneGateConfig(): Promise<DeferredPhoneGateConfigResult> {
try {
const policy = await getInstanceConfigRepository().getInstancePolicyConfig();
if (!resolveDeferredPhoneGateEnabled(policy)) {
return {status: 'disabled', config: DISABLED_CONFIG};
}
return {
status: 'ok',
config: {
enabled: true,
windowMs: policy.deferred_phone_gate_window_hours * ms('1 hour'),
memberThreshold: policy.deferred_phone_gate_member_threshold,
},
};
} catch (error) {
Logger.warn({error}, 'Failed to read deferred phone gate config');
return {status: 'unreadable', config: DISABLED_CONFIG};
}
}
export async function deferPhoneFlagsUntilCommunityJoin(flagBits: number): Promise<number> {
if ((flagBits & DEFERRABLE_PHONE_FLAGS) === 0 || (flagBits & NEVER_DEFERRABLE_PHONE_FLAGS) !== 0) {
return flagBits;
}
const {status} = await getDeferredPhoneGateConfig();
if (status !== 'ok') {
return flagBits;
}
return flagBits | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
}
export function guildTriggersPhoneGate(guild: Guild, memberThreshold: number): boolean {
return guild.features.has(GuildFeatures.DISCOVERABLE) || guild.memberCount > memberThreshold;
}
type DeferredPhoneGateOutcome =
| {applies: false; reason: 'gate_disabled' | 'already_verified' | 'guild_below_threshold' | 'outside_window'}
| {applies: true; flags: number};
export function evaluateDeferredPhoneGate(
user: User,
guild: Guild,
config: DeferredPhoneGateConfig,
now: number,
): DeferredPhoneGateOutcome {
if (!config.enabled) {
return {applies: false, reason: 'gate_disabled'};
}
if (user.hasVerifiedPhone) {
return {applies: false, reason: 'already_verified'};
}
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
return {applies: false, reason: 'guild_below_threshold'};
}
if (now - snowflakeToDate(BigInt(user.id)).getTime() >= config.windowMs) {
return {applies: false, reason: 'outside_window'};
}
return {applies: true, flags: (user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN};
}
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
let cachedEnabled = false;
export function resolveDeferredPhoneGateEnabled(policy: {
deferred_phone_gate_enabled: boolean;
single_community_enabled: boolean;
}): boolean {
return policy.deferred_phone_gate_enabled && !policy.single_community_enabled;
}
export function getCachedDeferredPhoneGateEnabled(): boolean {
return cachedEnabled;
}
export function setCachedDeferredPhoneGateEnabled(enabled: boolean): void {
cachedEnabled = enabled;
}
@@ -1,6 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
imposePhoneRequirements,
PremiumFlags,
SuspiciousActivityFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import type {RpcSessionTimings} from '@fluxer/schema/src/domains/rpc/RpcSchemas';
import {Config} from '../Config';
import type {UserRow} from '../database/types/UserTypes';
@@ -370,12 +376,14 @@ export class RpcSessionStartService {
timeRpcStepSync(
timingSteps,
'check_required_inbound_phone_flags_already_set',
() => (user.suspiciousActivityFlags & requiredFlags) === requiredFlags,
() =>
(user.suspiciousActivityFlags & requiredFlags) === requiredFlags &&
(user.suspiciousActivityFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0,
)
) {
return null;
}
const newFlags = user.suspiciousActivityFlags | requiredFlags;
const newFlags = imposePhoneRequirements(user.suspiciousActivityFlags, requiredFlags);
try {
const updatedUser = await timeRpcStep(timingSteps, 'persist_inbound_phone_requirement', async () =>
this.deps.userRepository.patchUpsert(user.id, {suspicious_activity_flags: newFlags}, user.toRow()),
Loaded 100 of 1152 files, more files were not shown because too many files have changed in this diff. Show more